Skip to content

feat: the TUI redesign and config v2 (agent selection end to end) - #881

Closed
chhhee10 wants to merge 77 commits into
mainfrom
feat/tui-ux
Closed

chhhee10 wants to merge 77 commits into
mainfrom
feat/tui-ux

Conversation

@chhhee10

@chhhee10 chhhee10 commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Ready to ship

Beta: 1.0.11-beta.2 (npm next already has 1.0.11-beta.1). package.json, the Cargo workspace and hermes-plugin/plugin.yaml all carry it, and CHANGELOG.md has one 1.0.11-beta.2 section covering #881 and #880.

Merge order: #881 → #880 (#899 is already merged into both, see below). One catch: main only allows squash merges. If #881 is squashed first, #880 (stacked on feat/tui-ux) conflicts with main in about 10 files, because its own commits repeat #881's changes. Two clean ways round that:

#899 (daemon resets the policy rollback floor on identity change) is merged into feat/tui-ux (c3a3b2d) and carried into feat/otel and feat/fp-cli-parity, so it ships with #881. Close #899 once #881 lands.

Publish the beta (after the merge(s), from main):

gh workflow run publish.yml -R FailproofAI/failproofai --ref main -f dist_tag=next -f dry_run=true   # optional rehearsal
gh workflow run publish.yml -R FailproofAI/failproofai --ref main -f dist_tag=next

You can also use Actions → Publish to npm → Run workflow, with branch main, dist_tag next and dry_run off. Pass next explicitly: auto on main resolves a prerelease to beta. The workflow reads the version from package.json, refuses it if it is already on npm, builds the four daemon binaries, and creates tag v1.0.11-beta.2 plus a GitHub prerelease at the dispatched commit. It then publishes the platform packages and failproofai@1.0.11-beta.2 to next, and pushes chore: bump version to 1.0.11-beta.3 [skip ci] to main. Don't create the tag or release by hand: a release: published event would publish to beta, not next.

Summary

Implements the designer's TUI redesign handoff in two parts.

A new screen language for every CLI surface:

  • UPPERCASE headings and no boxes; ●/○ for on/off.
  • Pink for anything you type.
  • ✓ ▲ ✕ results, each followed by a fix line.
  • The logomark appears only on bare failproofai and failproofai config.
  • All of it is built from one kit, screenKit() in src/hooks/tui.ts.

failproofai config v2:

  • sudo, then one API-key field (Tab for open source), then which agents failproofai traces.
  • That one remembered list (agents: {selected, seen} in config.json) drives hooks, the daemon's collection, and backfill.

Version is 1.0.11-beta.1. 1.0.11-beta.0 is already on npm at dist-tag next, published on 2026-10-12 from int/oct-12-test for testing, so the beta cut is v1.0.11-beta.1.

What changed

Every screen in the new language:

  • the help index and every --help page
  • policies (list, add, show, --releases)
  • config --status and pause/resume
  • the dashboard launch screen (● live)
  • audit with per-agent progress bars, plus sign-in and scheduling
  • the pickers
  • errors, with a did-you-mean
  • the notices printed above a command
  • uninstall, now one y/N with a plan that says what goes
  • publish
  • jev status, setup, test, models and remove
  • flush, migrate and update
  • harness, backfill, and --connect / --disconnect / rename

Config v2 (src/hooks/configure-wizard.ts, plus promptCloudKey() in tui.ts):

Steps

  • DAEMON: the same probe, skew, broken-worker and stale-unit logic. sudo's own prompt is erased after it answers, so the step collapses to one line.
  • CONNECT: one masked field with the host in the heading.
    • A saved key, from either credential, is shown at once and checked in the background.
    • Enter on an empty field keeps the connection exactly as it is.
    • Tab never disconnects (--disconnect does).
    • Keys are checked by introspect, which has no side effect. A refused key is never written.
  • AGENTS: all 12 listed.
    • A first run ticks the detected ones. A re-run opens on the saved list, with newly detected agents ticked and tagged. An upgrade opens on what is hooked today.
    • Tags new on this machine, will be added and will be removed update live.

Apply

  • It applies after the last answer, and Esc before that changes nothing.
  • The order is load-bearing: daemon, hooks, the backfill request, the agent list, the connection.
  • Unticking removes failproofai's entries from that agent's user settings only. It never calls removeHooks, which would reset policy config in every scope.

Flags

  • --oss, --agents a,b|all.
  • --token K --machine-label L now sets up under that name instead of renaming.
  • An unknown option is an error instead of being silently ignored.

Agent selection end to end:

  • Daemon (crates/failproofaid/src/{agents,backfill}.rs): a harness that isn't selected starts no collector task. Backfill requests can name agents and a kind (user / added), first-sight windows are per agent, and stale added requests are dropped.
  • CLI: src/hooks/backfill-request.ts is the one writer of backfill-request.json. It is atomic and merges with a pending request instead of replacing it.
  • Other hook writers keep the list true (src/hooks/agent-selection.ts): policies --install/--uninstall, policies add, policies remove all and the dashboard's agent panel each print a one-line note.
  • backfill: traced agents only, plus --agents. Sessions are counted by the audit adapters, all 12 agents.
  • config --status: agents traced / not traced / not installed and delivery health. No dashboard row, no per-day counts, and no per-agent rows (1.0.10 had one per Hermes profile).
  • Hooks from an untraced agent are logged once per session and evaluated as usual. They are never allowed because of the list.

Cleanup: 335 dead lines of the retired helpers removed from tui.ts.

Fixes found along the way

  • Daemon, latent on main: a config edit landing while the collector restarted after a backfill was recorded as running and never applied. Setup v2 hits that window on every re-add. The e2e test failed 2 of 3 runs before the fix and passed 6 of 6 after.
  • backfill 6m (no --since) silently ran 30 days.
  • config --token K --machine-label L ran a rename and ignored the key.
  • config with an unknown option ran full setup with it ignored.
  • config --disconnect said to restart the daemon. It stops within a few seconds on its own.
  • policies showed nothing on a machine still enforcing pre-pack builtins. It now shows a BUILT IN count.
  • notEnforcingReason called a machine whose only pack was refused "not enforcing". That pack denies.
  • A pending backfill request was overwritten by the next one.
  • Hard-coded escape codes in the --install duplicate-scope warning printed into pipes and under NO_COLOR.
  • Keys on argv: several messages taught --token <key>. They now point at failproofai config.
  • --cli errors named 8 of the 12 agents.
  • The did-you-mean answered install with uninstall.
  • uninstall claimed settings "survive a reinstall".

Product decisions behind this (answered before implementation)

  • Agents: every agent is listed; the detected ones are ticked on a first run, and upgrades keep what's protected today.
  • Other hook commands turn an agent back on or off in the list, with a note.
  • Everything is written at the end. The pre-apply lines say what was checked; DONE says what was written.
  • Tab keeps an existing connection. The saved key is shown instantly and checked quietly.
  • Copy:
    • Explanatory and privacy paragraphs are dropped, except the audit --schedule consent line.
    • There is no "7 days of history" wording.
    • One generic "Policies are not enforcing yet." warning everywhere.
  • Commands, URLs, paths and ids are never cut. Prose shortens with … on a terminal only, and pipes are never cut.
  • Help: a taller help index; per-command help is cut to usage, options and examples.
  • Builds: "● live" on launch, did-you-mean hints, delivery health, per-agent audit bars, and policies --all. Today's counts were built and then dropped from config --status on review.

Worth a reviewer's eye

  1. Copy shortened from the design to fit 80 columns: "type to replace it", "from last setup" / "as protected today", and the index's config row.
  2. The empty machine. An interactive first run with nothing detected opens with nothing ticked, and at least one must be picked. An unattended run with nothing detected, hooked or saved takes all 12. A saved empty list stays empty.
  3. D2 side effects:
    • A project-scope install traces the agent machine-wide.
    • On a machine with no saved list, a user-scope removal saves this machine's agents (detected, or hooked at user scope) minus X. It no longer saves "every agent but X", which made the next config hook agents that aren't installed.
  4. Uninstall: y removes the daemon too, as --yes always did. policies --uninstall is the hooks-only route.
  5. Overlap with feat: TUI redesign, config v2 and OpenTelemetry setup (one PR: #881 + #899 + OTel) #880 (OTEL): it adds an OTEL question to the old wizard flow and touches the same daemon files. Whichever merges second ports the other.

Pre-beta review

Before asking for review I ran a five-slice adversarial review of this branch: setup and daemon, CLI writers, rendering, tests and CI, and security. Each finding went to an independent verifier. 26 of 27 findings survived verification, and CodeRabbit's two comments checked out too. All 28, plus the one CodeRabbit raised on the second pass, are addressed in this PR except the items listed below. Every behaviour fix has a test that fails without it.

  • Re-tracing an agent through policies --install, policies add or the dashboard panel resumed its old collector cursors. It would have uploaded what was written while the agent was untraced. One helper (requestFreshStart) now writes the added request on every path, before the selection, in the same order as setup.
  • Upgrade setup left a Hermes on the legacy shell hooks unticked, which would have stopped collecting it. It also sent an added request (a full re-read) for agents the daemon was already collecting.
  • A removal with no saved list saved "every agent but X". The next config would then have hooked agents that aren't installed.
  • config --oss and keyless unattended runs stopped, exiting 0, on a bad FAILPROOFAI_CLOUD_URL they never use.
  • The daemon:
    • A request landing mid-tick is re-checked after the old collector stops.
    • It claims backfill-request.json by renaming it before reading, instead of reading and then deleting it, so a CLI write that lands in between is kept for the next tick rather than deleted unread. That race is latent on main; this PR adds writers to it (CodeRabbit).
  • notEnforcingReason now excludes refused Jev-only packs, as listHooks does (CodeRabbit).
  • The cloud-key prompt: Enter that waited on a saved key that came back refused now shows the empty-field error (CodeRabbit).
  • CLI:
    • did-you-mean answered --uninstall and unpause with their opposites.
    • --machine-label was dropped silently with --agents/--oss.
    • --send-transcripts (older provisioning scripts) failed the new strict flag check. It is accepted again as a no-op.
    • backfill --dry-run suggested a command without its --since.
    • update/migrate/flush --wait put the ✕ on the wrong line.
  • Rendering:
    • Collapsed lines and help options were cut at the terminal edge; they now print whole, or wrap.
    • Colour escape codes leaked into a redirected stderr.
  • Tests:
    • Four suites read or wrote the real ~/.failproofai, including the wizard tests' FAILPROOFAI_HOME; all four are isolated now.
    • The deleted logo-shape checks are restored.
    • New tests pin that an untraced agent's events are still evaluated, never allowed, and that failproofai uninstall leaves the agent list alone.
  • Dependencies: OSV-Scanner was red (also on main). next 16.3.8, sharp 0.35.5 and source-map-js 1.2.2 fix it, plus the SDKs' dev lockfiles.

Left open, deliberately:

  • Two CLI processes writing a backfill request in the same instant can still overwrite each other. Closing that needs a lock both the CLI and the daemon share.
  • Merging a pending added request into a user one can widen it to a windowed rewind for one 5s tick. A clean fix needs a new field the daemon reads.
  • DONE doesn't list the hooks written or removed. That's a layout change.

Testing

  • Lint and tsc: clean.
  • Unit and e2e: 8,316 unit tests pass and 357 e2e tests pass, on the final HEAD. The real ~/.failproofai was checksummed before and after and is unchanged.
  • Build: bun run build works.
  • Rust: cargo fmt --check and clippy -D warnings are clean, and cargo test --workspace passes.
  • SDKs, for the dependency bump: the TS SDK's typecheck, lint, build and 548 tests pass; the Python SDK's base suite (1,052) and its frameworks suite on 3.12 (1,348) pass; OSV-Scanner passes locally with CI's image.
  • Docker clean install: passes, CLAUDE.md's smoke test plus new-look checks.
  • Audit cache: the key is unchanged after every step (engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93), so no user rescans their history at upgrade. Checked with a fingerprint script run after each change, because a new top-level name in the bundle can rename policy locals.
  • Timing tests: a few failed only under heavy machine load from other processes. They pass when re-run alone, and they predate this branch.

Not in this PR

  • Docs: docs/ is not touched; the stale statements are listed below.
  • Jev copy: it still suggests config --token <key> in 11 places, 5 of them pinned by tests.
  • No time window yet for the database-backed agents (Goose, OpenCode, Devin). backfill says a backfill re-sends their whole history.

Docs left stale (follow-up, docs/ deliberately untouched)

A read-only survey of docs/ against this branch found 10 statements this PR makes untrue. Each of the 14 translations (docs/{ar,de,…,zh}/) repeats the same lines, at the same line numbers except reference/failproof-cli.mdx, which is 8 lines shorter.

Page Says Now
start/setup.mdx:21 pick Not now — stay local when setup asks to connect There is no connect question. One masked API key › field; Tab or config --oss stays on open source; then the agents picker
policies/deploy.mdx:31 config asks whether to connect, then takes the key The first screen is the key field. Enter connects; Tab uses open source, or keeps an existing connection (tab skip)
start/setup.mdx:73 --machine-label only renames; it never runs setup Alone it still renames. --token K --machine-label L sets up and connects under L
reference/failproof-cli.mdx:87 the same --machine-label row The same change; help reads "Rename this machine; with --token, set up under it"
reference/failproof-cli.mdx:28 config "wires hooks into every agent CLI it finds" Hooks follow the remembered agent list: detected agents ticked on a first run, the saved list on a re-run, what is hooked today on an upgrade
reference/failproof-cli.mdx:79 config flags table Missing --oss and --agents <a,b|all>; an unknown option is now an error
reference/failproof-cli.mdx:113 backfill takes --since, --dry-run Missing --agents; backfill re-sends traced agents only
reference/failproof-cli.mdx:46 policies lists every policy Off policies fold per category; policies --all (new) lists every one
policies/local-configuration.mdx:43 policies warns about an unknown policyParams key It shows one attention line, most severe first; the unknown-key warning only appears when nothing worse applies
reference/jev-providers.mdx:133 failproofai jev test ok · 523 ms title line Opens with the shared header failproof ai v<version> · Jev test · ok · 523 ms

These were checked and are still accurate: uninstall's single Continue? y/N, the loose descriptions of config --status, error formats (no page quotes one), policies show <pack>, and the token-exported flows that say config hooks "every agent it finds" (true for a first unattended run).

Summary by CodeRabbit

  • New Features
    • Redesigned terminal screens across CLI commands, including clearer status, policy, audit, and sign-in views.
    • Added agent selection to control which integrations are traced, with clearer notices when an agent is not selected.
    • Added dashboard startup details for project, policies, traced agents, and cloud connection.
    • Added scoped backfill options and progress updates during audits.
  • Bug Fixes
    • Improved CLI suggestions and guidance for connection, configuration, and daemon errors.
    • Clarified uninstall confirmation to show the planned changes, including service removal.

Hermes review

Field Value
Status Changes requested
Reviewed commit 43715fc5dd183827a774d0d6cd929c746c42f842
Policy revision 1d8f31d926828f3bae215c58f5b35baa44acbff0
Model gpt-5.6-terra
Duration 426s
Updated 2026-10-14T09:13:27.092115795+00:00

Summary

Three blocking data/consent defects remain in the new agent-selection backfill flow: offline re-selection can upload sessions recorded while untraced, request merging can expand a scoped historical upload, and a daemon crash can silently abandon a backfill request.

Changes

  • Adds persisted agent selection shared by hook installation, daemon collection, and backfill.
  • Introduces daemon-side scoped cursor rewinds and first-sight windows for backfill requests.
  • Redesigns CLI/audit terminal surfaces and configuration workflow.

Validation

  • Passed docker run --rm --network none -v /review/input/workspace:/work:ro -w /work -e RUSTUP_TOOLCHAIN=1.97.1-x86_64-unknown-linux-gnu rust:1.97 cargo metadata --no-deps --format-version 1 — Rust workspace manifests parsed successfully in an isolated container. (1s)
  • Skipped docker run --rm --network none -v /review/input/workspace:/work:ro -w /work -e RUSTUP_TOOLCHAIN=1.97.1-x86_64-unknown-linux-gnu -e CARGO_TARGET_DIR=/tmp/target rust:1.97 cargo test -p failproofaid backfill::tests --lib — The isolated container has no vendored Cargo registry and network access is disabled, so dependencies could not be resolved. (90s)
  • Skipped docker run --rm --network none -v /review/input/workspace:/work:ro -w /work -e RUSTUP_TOOLCHAIN=1.97.1-x86_64-unknown-linux-gnu rust:1.97 cargo fmt --check — The isolated Rust image does not include the rustfmt component required by the repository toolchain. (1s)

Findings

  • High/High Offline re-selection can upload sessions recorded while the agent was untraced — requestFreshStart is called with collectingAfter && sessionsOn at line 1262, so re-selecting an agent while no ingest credential exists writes no added request; the new selection is nevertheless persisted at line 1264. After a later reconnect, that agent is already in the saved baseline and therefore produces no fresh-start request, so the daemon resumes its pre-deselection cursor and uploads sessions written during the untraced interval. (src/hooks/configure-wizard.ts:1262)
  • High/High Merging a scoped backfill widens a re-added agent's upload window — When an added request for one agent is pending, mergeBackfillRequests unions its agents with a later scoped user request and converts both to the user's sinceMs window (lines 80-85). The included test explicitly produces user(..., ["goose", "claude"]) from added(["goose"]) plus a Claude-only request. The daemon applies that user window to every named agent, so Goose can resend a much longer history than its seven-day re-add window although it was not named by backfill --agents claude. (src/hooks/backfill-request.ts:80)
1 advisory finding
  • Medium/High A claimed backfill request is abandoned if the daemon crashes — take renames backfill-request.json to the fixed .claimed name (lines 467-478), then read_claimed deletes it before the caller applies the rewind (lines 481-484). A crash after the rename leaves only .claimed; subsequent ticks inspect only the original path, so the requested backfill is never applied. A crash after deletion similarly loses an accepted request before apply_backfill. (crates/failproofaid/src/backfill.rs:477)

Open questions

None.

Policy overrides

None.

October 14 adversarial e2e fixes

What changed and why

  • Disconnect removes both the active cloud manifest and cached desired state. Removing only active.json allowed a daemon stopped during disconnect to rebuild the disconnected deployment at startup.
  • Reconnect snapshots the previous credentials, replaces verified capability slots, removes refused/missing slots, and keeps unknown slots only for an unchanged token and origin. Credential publication is atomic; the wizard/status flag split keys and removal messages mask the previous key.
  • A successful same-org ingest-key replacement makes .c401/.c403 batches retryable and requests a flush. Cross-org/unconfirmed-org refusals are archived under state/previous-org/; a flush retries fresh retryable parked batches without retrying unchanged auth refusals. connectedAt excludes obsolete credential errors from status.
  • Org switches establish org.since, cancel pending backfill, remove old policy references, and announce the recording boundary. Collector key/boundary load from one snapshot; boundary changes quarantine spool/failed queues after shutdown, including stopped-daemon switches. SpoolWriter drops/counts older events with source-mtime fallback; uploader also filters SDK batches; rewinds clamp to the boundary and old files are not rediscovered.
  • Added opt-in real codex exec e2e proof using deterministic local Cloud/model responses, a real daemon/worker and an isolated PRoot system-config mount. It checks both managed blocking with no trust hash and the unprivileged warning/not-enforcing path.
  • CI discovered newly reported Rustls advisories RUSTSEC-2026-0346 through -0349 affecting unchanged 0.23.45; a separate lock-only commit updates to fixed 0.23.46. Rust workspace tests and Clippy pass; the repair commit has all 35 checks green.

Testing

  • Added unit tests for active + desired-state removal and desired-state-only removal; all 12 cloud-managed-policy tests pass.
  • Full unit suite run: 8,340 passed, 10 skipped, 4 failed. Both strict timing tests pass on isolated rerun (458 passed across the three rechecked suites). Two daemon-download fixture tests assume x64 on this ARM host; both reproduce on archived origin/main (26 passed, 2 failed), and that test file has no diff against main. Logs: /job/artifacts/main-daemon-download-arm.log.
  • E2e after building the public API: 355 passed, 6 skipped.
  • Lint (existing warnings only), TypeScript typecheck, cargo test --workspace, cargo fmt --check, and cargo clippy --workspace --all-targets -- -D warnings pass.
  • Full production build passes.
  • Reconnect: full suite 8,362 passed, 10 skipped, only the same two reproduced main-branch ARM fixture failures; e2e 355 passed, 6 skipped. Added 18 reconciliation/recovery tests, split-key wizard/status tests, refusal-age filtering, and a wiremock-backed Rust flush delivery test. All Rust workspace tests, Clippy, format, lint, typecheck and production build pass.
  • Org boundary: 8,371 unit tests passed, 10 skipped, same two known main-branch ARM fixture failures; e2e 355 passed, 6 skipped. Full Rust workspace tests/fmt/Clippy, lint/typecheck and production build pass; clean tarball install validates the public API policy. Real daemon + HTTP ingest A/B test covers live switch, backfill and reselect; zero-idle engine test covers delayed old agent_end and old-mtime files.
  • Final local verification: 8,371 unit tests passed, 10 skipped, only the two reproduced base ARM fixture failures. Default e2e 355 passed/7 skipped (new real-Codex proof requires explicit external prerequisites). Opt-in real-Codex proof passed twice; full lint/typecheck pass. All Rust tests/fmt/Clippy passed after Rustls patch.
  • Opt-in proof command: FAILPROOFAI_CODEX_HEADLESS_PROOF=1 FAILPROOFAI_TEST_PROOT=<proot> FAILPROOFAI_TEST_DAEMON=<built-daemon> bun run test:e2e __tests__/e2e/hooks/codex-headless-trust.e2e.test.ts. Codex must be on PATH and CLI/worker bundles built. Logs under /job/artifacts/codex-proof-final.log, with user-level captures codex-managed-exec.log, codex-managed-status.log, codex-no-sudo-exec.log, codex-no-sudo-status.log.
  • Additional real-daemon user check passed: revoked key parks a session as .c401, same-org replacement delivers it and removes the refusal; collector-only removes cloud and stops policy polling with “for reporting only” status; policies-only removes ingest and stops collection. Fixture script/log: /job/artifacts/reconnect-user-proof.ts and reconnect-user-proof.log. Corrected the Codex fixture to introspect's snake_case org fields and explicitly assert stored org id; the proof passes with this assertion.

Integration notes

Files changed here also touched by stacked feat/otel PR #880:

  • src/hooks/collector-config.ts — preserve OTel output settings while retaining verification HTTP status.
  • src/hooks/fp-config.ts — carry connectedAt and atomic credential publication alongside OTel credential/schema changes.
  • src/hooks/configure-wizard.ts — split-key warning and reconciliation outcome lines; do not rewrite OTel setup.
  • __tests__/hooks/configure-wizard.test.ts — split-key test alongside OTel wizard coverage.
  • crates/failproofaid/src/main.rs — preserve boundary preparation and upload filtering when integrating additive OTel tasks.
  • crates/failproofaid/tests/agent_selection_e2e.rs — A/B delivery regression alongside OTel capture tests.
  • crates/fpai-collect/Cargo.toml — retain the time/parsing feature alongside OTel dependencies.
  • crates/fpai-collect/src/config.rs — OrgBoundary and single credential snapshot must remain in compared CollectorConfig alongside OTel settings.
  • crates/fpai-collect/src/filetail.rs — retain event-mtime source binding and first-sight boundary clamp while integrating OTel tailing changes.
  • crates/fpai-collect/src/lib.rs — retain the org_boundary module export alongside OTel.
  • crates/fpai-collect/tests/codex_source.rs — accelerated idle-end/boundary regression alongside OTel source changes.
  • crates/fpai-collect/tests/config.rs — boundary reload contract alongside OTel config.
  • Cargo.lock — preserve the Rustls 0.23.46 security patch alongside OTel dependencies.

Left open

Real Codex 0.159.3 blocked a policy downloaded from a local HTTP Cloud with no trust step under PRoot's isolated /etc/codex mount; status says system enforcing. Without the mount/sudo, status warns/not enforcing and the same command executes. Actual sudo and Docker are unavailable; real hosted Cloud org credentials were not supplied, so those exact deployment checks remain open. The two x64-assuming daemon-download tests also fail on origin/main on ARM (base reproduction log recorded above); no tests were weakened. #899 remains separate and is still required for full daemon policy-floor identity behavior. Final corrected proof-test commit CI is being watched.

Step 1 of the TUI redesign handoff: add the redesign's colour roles to
HUES and expose them through paint() and brandAnsi(), so no caller ever
hard-codes an escape.

- ink3 (#767f8b, labels, heading meta, key hints) and track (#3e434c,
  the empty part of a progress bar) are the designed hex at 24-bit and
  the SGR 2 dim attribute below it.
- err (#f07178) is the red for failed lines: 203 at the 256 tier, 31 at
  the basic tier.
- ink2 is the identity: descriptions keep the terminal's own foreground.

Deviations from the handoff, as agreed in the review (D12):
- The handoff suggested fixed 256 greys (248/243/238) and a basic-tier
  bright black (90). A fixed grey fights every theme that is not ours,
  and 90 is the background colour in Solarized Dark, so ink3 and track
  stay the dim attribute below 24-bit, as dim already does.
- The designed ink2 #a7adb6 is tuned for dark terminals and measures
  about 2.2:1 on a light one. Nothing detects the background, so
  descriptions are never painted.
- err uses 203 instead of the suggested 204: 204 is nearest by distance
  but differs from the brand pink (168) only in its red channel and
  reads as pink at that tier.

Audit cache keys are unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93): the roles are properties on existing
objects, so the bundle gains no top-level name.
Step 2 of the TUI redesign handoff: header, head, rows, kv, cmd, the
●/○ state glyphs, ok/caution/fail, keys, bar, logo and helpPage, which
match reference/screens.js FP.primitives. They are pure builders over
RenderOpts, colour on or off, so every screen built from them can be
asserted at any width without a pty.

Deviations from the handoff, as agreed in the review:
- Nothing copyable is ever cut (D9). Commands, URLs, paths, ids and kv
  values are left whole. Only prose (row descriptions) is shortened, with
  `…`, and only when a caller sets `fit` for a terminal; piped output is
  never cut.
- Columns are derived from the block, with a guaranteed two-space gap,
  instead of the reference's fixed widths. The longest real policy name
  (require-no-conflicts-before-stop) is exactly the 32 columns the design
  fixes, so a fixed column runs it into its description.
- The logomark returns nothing below its minimum width. The old ▮▮
  one-liner is retired with that mark.
- Without colour, a progress bar's empty part is blank rather than a run
  of ━, which would show no progress at all.
- Everything lives inside one exported factory, screenKit(). A new
  top-level name that matches a local in a builtin policy or an audit
  detector (cmd, row, head, …) makes the bundler rename that local and
  rescans every user's audit history. The bundle gains one name, and
  engineVersion 10c367f637740d67 / detectorVersion 895c2fedece85e93 are
  unchanged.
Step 6a of the TUI redesign handoff. config.json gains an optional
`agents: { selected, seen }`: the remembered answer to the new setup's
"which agents should failproofai trace?". `selected` will drive hooks,
collection and backfill. `seen` is what was detected at the last setup,
so a newly installed agent can be offered as new.

- Absent means today's behaviour, every agent. writeConfig emits nothing
  in that case, so an untouched config file keeps its exact bytes, and a
  garbage value reads as absent. A broken file can never quietly switch
  tracing off.
- updateConfig carries the selection forward unless a patch replaces it
  (`null` clears it). Every mutation path goes through it, the wizard's
  own connect step among them, and an unrelated write that dropped the
  key would silently turn every unticked agent back on.
- The ids are kept as written, minus non-strings and repeats. The daemon
  owns the list of real agents and warns about an unknown one.
- isAgentTraced(id) is the one predicate per-agent surfaces ask.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93).
Groundwork for the redesigned policies and launch screens, which both
warn when policies are not enforcing. One predicate,
notEnforcingReason(), answers for both so they can never disagree. It
returns "no-hooks" (no agent calls failproofai), "no-policies" (wired,
nothing on, counting policies switched off after install) or
"observe-only", and null when something enforces.

It reads files only, so it is cheap enough for the launch path:
installed packs, cloud-managed policies, configured custom policy files,
convention files on disk (counted, never imported) and the legacy
migration shim.

Deliberately not causes, because each needs its own words:
- A refused pack and a down daemon on a daemon-configured machine fail
  closed and DENY calls. "Not enforcing" would say the opposite of what
  is happening.
- A session pause belongs to one session, not to the machine.

Audit cache keys unchanged.
Step 5 of the TUI redesign handoff. selectOne and multiSelect are drawn
like the design's policiesAdd and agentPicker screens:
- an UPPERCASE heading with grey meta, by default a live "n of m
  selected" that updates on every toggle
- a pink `›` cursor gutter and a bold cursor row; ■ ticked and □ not
- bold section names, `↑/↓ n more` when the list scrolls
- the key hints at the foot
Answered prompts collapse to the heading and `✓ <answer>`, or to the
caller's own lines. The spine glyphs are gone from both.

- The window is 12 rows, not 8. Twelve reproduces every frame in the
  design; eight cannot show the nine agents of its setup screen. The
  WINDOW identifier itself is kept, because the audit cache keys on
  bundled names.
- Plain `a` toggles all, as the design's hints say. ctrl+a stays as an
  alias.
- New opt-in options for the config flow's agents step: a meta
  function, per-row tags recomputed on every toggle, the ✕ message for
  minSelected, and a caller-defined collapsed form. selectOne gains
  `meta`, `collapsed` and `spaceSelects`.
- The `policies -i` / `--install` / `--uninstall` agent menu was
  install-prompt.ts's own raw-mode loop, with a two-write (non-atomic)
  repaint and a colour gate that ignored pipes. It now uses selectOne.
  It keeps its "not installed" section of forward installs, Space still
  picks and Esc still exits 130. Its aggregate row reads "All N agents",
  as the design spells it.
- The two raw yellow warnings in the same file are now ▲ lines through
  the palette, so they respect NO_COLOR and pipes, and they drop a
  stale six-agent list.
- install-prompt.ts's searchable policy picker had no caller since
  `--install` stopped choosing policies. It is deleted with its tests
  (approved), and the manager tests keep their real assertion: setup
  writes the policy selection back unchanged.
- The pack pickers in pack-cli.ts take the heading meta instead of
  hand-written hint strings. The pack picker is preceded by the design's
  `failproof ai  v…  ·  Add <pack>` header, the per-pack agent picker
  shows display names, and Jev checks are still stated before the list.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93). Full unit suite green.
Step 3a of the TUI redesign handoff. Every `<command> --help` is now the
design's helpPage: the wordmark header, then USAGE, OPTIONS and
EXAMPLES, and nothing else (decision D11). Explanations and the privacy
footer are gone (D6), and docs/ is not touched.

Each page was rebuilt against its command's real argument parsing, not
its old text. Every real flag is kept with its real default, and the
reference's misses are corrected:
- config: adds --session, --connect and --machine-id. --machine-label
  says it renames and never runs setup, which is what it does today.
- policies: adds the --install/--uninstall lanes, -i/-u, --only, the
  12 --cli ids and `all` for --scope when removing.
- jev: one usage row per subcommand. The design's `status | test |
  models | remove` runs as a shell pipeline if pasted.
- publish: adds --min-cli-version and --entry.
- flush --wait says what it actually checks: the queue is empty.
- update --no-daemon: "migrate everything", not "settings only".
- uninstall --yes: also removes the daemon, and is needed without a TTY.
- backfill --since documents 1y and YYYY-MM-DD, which it accepts.
- Examples never put a key on argv: config shows `read -rs` into
  FAILPROOFAI_CLOUD_TOKEN, the form the CLI's own warning recommends.

The index follows the design: GET STARTED (three numbered steps that end
in a command), then SET UP, ENFORCE, OBSERVE and LESS OFTEN, one row per
command, then EXAMPLES. It fits 80 columns with light rewording (taller
is allowed, D10). `config --status` takes the --token example's slot,
and the last line names `failproofai -v` and the docs.

- `policies add|remove|show --help` prints the one policies page, which
  carries every flag of every lane, so `help policies add` lands right.
- Prose is shortened with `…` only on a terminal; commands are never
  cut (D9), and piped output never is.
- The hook page is titled `hook`, the topic you type.
- The usage `harness` and `jev` print after a mistake now uses the same
  rows as their help pages.
- Fix: `policies remove` without a terminal suggested
  `remove <owner>/<repo> --policy …`. Pack-lane remove takes an id and
  no flags, so following that hint uninstalled the whole pack.

Tests: help-index.test.ts has a new contract for the taller index:
- at most 36 lines and 80 columns, `·` the only wide character, no
  escapes in a pipe
- six headings in order, and three numbered steps ending in a command
- the indexed commands must EQUAL bin's SUBCOMMANDS
- `help X` byte-identical to `X --help` for every one of them
- no module-scope assertions, so a layout change fails tests, not
  collection
The bare-publish guard is re-anchored on markers the new page really
prints, so it cannot pass vacuously. The audit help test drops the
local-only promise D6 removed and checks the option column instead of
wrapped lines.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93). Full unit suite green.
Step 6b of the config v2 plan: the Rust half of "which agents does
failproofai trace". The CLI writes the selection (6a) and the backfill
requests; the daemon obeys both.

Contract
- config.json may carry a top-level `agents: {selected: [...], seen: [...]}`.
  fpai-collect's config::load parses `selected` into a new
  CollectorConfig::agents (AgentSelection), so the existing 5-second
  PartialEq compare cycles the collector when it changes, with no restart.
  `seen` is not read, so a newly detected agent cycles nothing. No `agents`
  or no `selected` means every harness, as before. A value that cannot be
  read counts as absent, the way readAgents in fp-config.ts reads it, and
  gets a journal line. No deny_unknown_fields, so older daemons keep
  ignoring the key.
- One table, agents::SOURCES, maps every source (= top-level cursor dir)
  to its agent: claude <- claude, claude-subagent; openclaw <- openclaw,
  openclaw-sqlite; the other ten 1:1; `hooks` belongs to no agent. The
  collector gate and the backfill rewind both resolve through it. Nested
  stores inherit their top-level dir's agent; unknown top-level dirs are
  skipped by scoped rewinds. HARNESS_KEYS is untouched.
- An unselected harness starts no task: not its default roots, not its
  extra paths, not any Hermes profile. Hook activity and SDK spool delivery
  always run. Journal: "agents.selected names <x>, which has no collector
  source; ..." once per build, outside the sessions block; "<h> is not
  traced; its N extra capture paths are idle"; and one line naming what a
  selection collects.
- state/backfill-request.json is {sinceMs, requestedAtMs, agents?, kind?}.
  No kind = "user"; no agents = every selected agent (every agent when
  there is no selection). "user" forgets the cursors of files modified at
  or after sinceMs in the named agents' top-level AND extra-path stores,
  plus `hooks`; Hermes profile stores are left alone. "added" forgets every
  cursor of the named agents, Hermes profiles included, never touches
  `hooks`, and sets their first-sight window to the default 7 days. Named
  agents are taken as named, not intersected with the selection: config
  writes the request before the selection that ticks them.

Deviations from today
- A user backfill now rewinds nested extra-path stores (<source>/<label>/,
  Hermes extras included). The old rewind read top-level stores only.
- The process-global, sticky BACKFILL_SINCE_DAYS is replaced by a
  per-agent window (backfill::Windows), captured when tasks are built and
  kept until a later request for that agent. A scoped backfill no longer
  widens other agents' windows, and an agent added after a backfill starts
  on 7 days. A sinceMs in the future gives a 1-day window instead of 0.
- "added" requests older than 10 minutes, or without requestedAtMs, are
  dropped with a journal line; "user" requests are honoured at any age.
  An unknown kind or a non-list `agents` is discarded, not guessed at.
- A pending request is applied before the collector first starts and
  before it restarts after collection is re-enabled, as well as on each
  tick (still ahead of the config compare).
- filetail's "file source started" line now carries since_days.

Tests
- fpai-collect: AgentSelection parsing (absent, seen-only, garbage, the
  CLI's normalisation); a selection change alters CollectorConfig and a
  seen-only change does not; CursorStore::forget_all and
  forget_modified_since.
- failproofaid unit: the exact task list with no agents key (regression);
  an unselected agent's tasks, extra paths and Hermes profiles absent; the
  table covers every registered task and equals HARNESS_KEYS; journal
  text; request parsing and scope; per-agent windows (a scoped backfill
  does not widen another agent's; an agent added after a 30-day backfill
  gets 7); user rewind (claude reaches claude-subagent, openclaw reaches
  openclaw-sqlite, nested extra-path stores rewound, Hermes profiles not,
  hooks rewound); added rewind (everything incl. profiles, hooks
  untouched); a request with no agents and no kind covers the selected
  agents; a stale added request is dropped, a stale user one honoured.
- agent_selection_e2e, against the real binary with an isolated HOME: no
  agents key starts all 18 tasks; an unselected agent and its extra path
  never start; an unknown name is warned about on every build with
  sessions off; a mid-run selection change cycles to the right sources and
  a seen-only edit does not; a pending request is applied before the first
  start; a stale added request is dropped; a fresh one forgets everything
  but hooks; a re-added agent never resumes stale cursors even when the
  request is read before the selection lands.
Step 6c of the TUI redesign handoff: the CONNECT TO CLOUD field that
config v2 draws, as its own prompt (promptCloudKey) rather than options on
promptText. promptText keeps its one-row rule, which its other callers
depend on.

What it does:
- One masked "API key ›" field, one • per character, under an UPPERCASE
  heading with the destination host as grey meta.
- Tab resolves OPEN_SOURCE, a sentinel like BACK.
- A saved key is drawn at once and checked in the background (D5).
  - works: ✓ line
  - refused: ▲ line, and the step behaves as if no key were saved
  - unreachable: ▲ line, and Enter still keeps it, unverified
  Enter waits for a check still in flight.
- A typed key is checked by the caller's function, with a spinner after
  150 ms (80 ms frames). A refusal prints one ✕ line, keeps the key in the
  field and switches the hints to "enter try again".
- Under 8 characters: an inline ✕, no server call. An empty Enter with no
  saved key: the caller's ✕ line.

Spec fix 11:
- A tab or newline inside a paste no longer switches to open source or
  submits halfway. Bracketed paste is turned on, and Node and Bun both
  report its start and end as keypresses.
- Keys are ignored while a typed key is being checked; Esc still cancels.
- Cursor and paste mode are restored on every exit, including a process
  exit while the prompt is open.

Copy change, flagged for review: the saved-key hint reads "type to replace
it" instead of the reference's "type a new key to replace it". The
reference is drawn at 104 columns; at 80 the longer line is cut through
the Tab hint, the only place Tab is mentioned.

collapseAfter(stdout, rows, fn) reserves rows, saves the cursor, runs fn
and erases back, so the wizard's DAEMON step can collapse sudo's own
password prompt to one line. The rows are reserved first so the prompt
never scrolls the saved position away.

Tests: 22. They cover every key path, the paste guard, the four saved-key
states, Enter waiting for the saved check, the spinner delay, terminal
restore, the key never reaching the output, colour on and off, and
collapseAfter with and without a terminal.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93).
Step 6d of the TUI redesign handoff. `failproofai config` is now
sudo → API key (Tab for open source) → which agents to trace → apply,
drawn in the 2026-10 screen language. The logomark and header open the
run, each step collapses to its heading and a ✓ line, and DONE says what
was written and whether anything enforces. The "Ready to apply?" step is
gone: Enter on the agents step is the last decision, and Esc anywhere
before it changes nothing (D3).

DAEMON
- Same logic: probe, version skew, broken worker, stale unit.
- sudo's own password prompt is erased after it answers, so the step
  collapses to "✓ Root granted to install failproofaid".
- The broken case keeps "every tool call is denied" on screen.
- The install still happens at apply, before anything else is written.

CONNECT TO CLOUD
- One field (promptCloudKey) with the host as heading meta.
- A saved key from either credential is shown at once and checked in the
  background (D5). Reading only the policy credential, as setup did,
  showed a collector-only machine as unconnected.
- Enter on an empty field keeps the saved connection exactly as it is:
  no connectToCloud, so collector settings are not reset. It writes
  mode: cloud if that is missing.
- Tab never disconnects (D4). On a connected machine the hint is
  "tab skip" and the line "Not connecting. The connection to <host> is
  unchanged."; --disconnect stays the way to disconnect.
- Keys are checked with introspect first (organisation and permissions,
  no side effect), with the old empty ingest probe on servers too old
  for introspect. A key with one of events:add / policies:pull is
  accepted with a ▲ note; one with neither is refused.
- A refused --token re-prompts on a terminal and still fails a headless
  run (spec fix 9). A --url or FAILPROOFAI_CLOUD_URL override is named
  in the heading.

AGENTS (D1: all twelve listed)
- A first run ticks what is detected. A re-run opens on the saved
  selection, with agents detected since then ticked and tagged "new on
  this machine". An upgraded machine opens on what is hooked at user
  scope today (spec fix 8: a gateway counts when any profile is hooked).
- Rows are tagged live: will be added / will be removed / not installed.
- seen accumulates across runs, so a PATH glitch cannot make an agent
  someone unticked arrive ticked as "new" later.
- No history wording (D7). A removal promises only what the code does.

Apply order (spec fix 3):
1. daemon
2. hooks for the chosen agents, and user-scope removal for the rest
3. the "added" backfill request
4. agents in config.json
5. the connection

Unticking takes failproofai's entries out of that agent's user settings
only. It never calls removeHooks, which also resets policy config in
every scope (spec fix 1). A test pins every policies-config.json
byte-identical.

backfill-request.ts is the one writer of backfill-request.json (spec
fix 6). Requests merge, keeping the stronger: a pending user request
stays user, the widest window wins, and no list means every agent. The
file is written atomically. An "added" request carries no sinceMs, so a
daemon older than `kind` drops it instead of rewinding every agent.
Config writes one only for agents added back, and only when something
will collect afterwards. `failproofai backfill` writes through it too.

The log-only check (spec fix 2): a hook event from an untraced agent is
evaluated as usual and logged once per session, through a bounded
record on disk. Allowing such a call would open a hole in a hook
somebody installed on purpose.

DONE:
- the generic "Policies are not enforcing yet." (D18) plus "Turn on
  ours", or "N policies are enforcing on M agents". The count comes
  from enforcingPolicyCount, which counts the way notEnforcingReason
  decides (spec fix 10).
- the custom policy files that will not load, moved here from the
  removed review step
- Jev's status lines, unchanged

Telemetry adds mode, agents_selected, agents_detected and
agents_changed. Counts only, never names.

Copy that differs from the reference, both for 80 columns:
- the agents meta reads "from last setup" / "as protected today"
- the DAEMON and CONNECT lines say what was checked, not what was
  written (D3)

Also: notEnforcingReason no longer calls a machine whose only pack was
refused "not enforcing". That pack fails closed and denies.

Removed with the review step: reviewLines, buildCompletionSummary,
policyNamesLine and their tests. Two review-screen tests are gone from
custom-policy-discovery.

Tests:
- configure-wizard: 85, rewritten for the new flow (decisions above,
  both Esc-before-apply cases, the byte-identical policy configs)
- backfill-request: 14 (read as the daemon reads, merge rules, atomic
  write, the backfill command)
- untraced-note: 5
- not-enforcing: 11 (refused pack, the count)

Full unit suite and e2e green. Audit cache keys unchanged
(engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93).
Step 3b of the TUI redesign: `failproofai policies`, the result of
`policies add <pack>` and `policies show <pack>`, built from screenKit()
and drawn as designed (decision D14).

- The listing names each pack once as a `pack` row, counts `enabled N of
  M`, and lists policies ● on or ○ off under UPPERCASE category headings
  with "n of m" meta. Off policies fold into one "○ k more off" line per
  category, and the screen closes on "See all M with failproofai policies
  --all".
- New bare `failproofai policies --all` lists every policy, nothing
  folded. Wired into the listing parser only; its help row lands with the
  help rewrite.
- At most one attention line, most severe first: a pack that will not
  load and fails closed (✕, it denies what it covers; fix `failproofai
  policies remove <id>`), then notEnforcingReason()'s one generic
  "Policies are not enforcing yet." (D18; fix `failproofai config`), then
  a custom policy file that is not running, hooks in more than one scope,
  and an unknown policyParams key. The policy_params_validation_warning
  event still fires for every unknown key, whichever line is shown.
- Custom, convention and cloud-managed policies get sections in the same
  style. The scopes subtitle, the `Config:` footer and the explanatory
  paragraphs are gone (D6).
- The listing is a pure renderPoliciesScreen() over a gathered value, so
  it is tested with colour on and off at any width. listHooks keeps every
  other behaviour, including mirroring convention policies into the user
  config. Descriptions are shortened only on a terminal; names, ids,
  paths and commands never.
- `policies add` opens with "✓ Turned on N policies from <id>@<version>:
  <reason>.", then per-category counts and one line per outcome fact
  (rename, pin, Jev checks, min-CLI note, warnings), and closes on "Turn
  on the other K with failproofai policies add <source> --all". It no
  longer says "Turned on" when nothing was: a carried re-add says "Kept",
  a merge says how many are on, and a pack with nothing on, or with only
  Jev checks, says it was installed. The five reasons stay distinct.
- `policies show` opens with kv rows: publisher (from the source owner,
  never the self-declared id), commit when there is one, "N in M
  categories ■ on by default", and effect / requires / jev checks rows
  when they apply; then ■/□ rows per category and "Install with …". No
  release date (D16).
- screenKit() gains failed (✕), selected (■), unselected (□) and meta (a
  grey qualifier) as properties; tui.ts gains no top-level name.

Judgment calls where the design is silent:
- A pack row carries grey tags rather than new states: `observe`; its
  Jev checks; `from <source>` when the source is not the repository its
  id names (keeps the old anti-spoofing display); and `failed to load:
  <reason>` for a refused pack, so the reason and its fix stay visible.
- Category headings are prefixed with the pack id only when two packs
  have regex policies, so the shipped FailproofAI/policies +
  FailproofAI/jev-policies pairing reads as designed.
- Every off policy folds, as briefed (the reference also draws a few off
  rows by name), and the "See all" line shows only when something folded.
- An empty machine shows the generic line, "Turn on ours: …", and one
  more line in the same shape, "Or anyone's: failproofai policies add
  <owner>/<repo>", to keep first-run-hint's "ours is not the only
  channel" intent.
- A cloud-managed observe policy keeps ● and is tagged `observe`, like an
  observe pack. A convention file that fails to load is a ✕ row but no
  attention line, matching the old warning set.
- The name column is derived from the names shown: at least the design's
  32, two spaces past the longest, shared by every pack section.
- `add`'s closing command uses the source as typed, never the id, which
  can name a repository the pack did not come from. "Pinned to" names
  the full source, owner/repo@tag.
- The Jev checks section on `show` keeps its rows and notes; only its
  heading moved to the new style.

Tests that pinned the old screens are updated deliberately, keeping each
intent, with negative chip pins rewritten as positive checks in the new
vocabulary; manager-cloud-listing now runs against a temp home. New tests
cover --all, folding, multi-pack prefixes, the observe note, precedence,
the show screen, colour on and off, and the new kit pieces.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93).
Step 3c of the TUI redesign: `failproofai config --status` and the
`--pause` / `--resume` confirmations, built from screenKit().

config --status (new src/hooks/config-status.ts)
- THIS MACHINE / CLOUD / ENFORCEMENT. Every kv row of all three sections
  goes through one kv() call, so they share one value column derived from
  the widest label (INV-24). Values are never cut.
- gatherStatusFacts() is the only part that reads the machine;
  renderStatusScreen() is pure, so every row and the order between
  problems are pinned without a daemon, a terminal or a clock.
- New data, approved in D16. The dashboard row probes the bind host from
  lib/dashboard-host.ts on port 8020 with a 150 ms connect. Delivery reads
  collector-health.json (nothing on this side read it before), the refused
  batches in state/failed and the spool backlog. Today's blocked and warned
  counts come from the hook-activity store's own windowed reader
  (getHookActivityEntriesSince); no second parser.
- At most one attention line, by severity: a daemon-configured machine
  whose daemon is not answering (every tool call denied), a pack that will
  not load (the calls it covers denied), the cloud refusing the key, not
  enforcing (notEnforcingReason), unhealthy Hermes profiles, the daemon
  down on a connected machine (macOS "unknown" gets its check command),
  other refused batches, then daemon/CLI version skew.
- Today's facts stay on screen as rows, so a worse problem cannot hide
  them: the skew on the daemon row, refused batches on the delivery row,
  one hermes row per unhealthy profile, macOS's unreadable service state
  on the daemon row, one row per paused session with its id and time left,
  and "Cloud-managed policies keep enforcing." whenever something is
  paused.

--pause / --resume (session-pause-cli.ts)
- "✓ Paused enforcement for this session until 14:32 (30 minutes).", with
  the duration actually granted (the 8h ceiling counts from
  firstPausedAt), an "Extended the pause ..." variant, then the cloud line
  and "Resume early:  failproofai config --resume". Resume answers in the
  same shape; errors are one ✕ sentence and one fix line.
- Clock, locale and time zone are injectable, so the tests are
  deterministic.

Also: kv() paints no empty colour span for an empty (continuation)
label, and flush-cli's spoolBacklog() adds the oldest queued batch's age
(pendingBatches counts through it).

Judgment calls
- The account row is "<org> on <host>". The host was today's "cloud
  connected to <url>" row; the design shows the org alone. With no org on
  record it reads "Connected to <host>". ", for reporting only" and
  "Not sending, connected for policy only" keep today's half-connection
  facts.
- The machine row keeps describeMachine's label plus 8-character id,
  because labels collide; the design shows the label alone.
- Dropped as fine print (D6): the layout version, the masked token row,
  healthy Hermes rows (with the copy-mode note), and the daemon
  paragraphs ("install it as a service ...").
- Two attention candidates the brief did not list. "Policies are not
  enforcing yet." is D18's shared predicate. A pack that will not load is
  listed above it, because such a pack fails closed and DENIES; calling
  that machine "not enforcing" would say the opposite of what happens.
- Delivery problems other than refusals stay on the row: no report yet, a
  record older than 2 minutes (the daemon rewrites it every 30 s), no
  delivery block (the daemon has no key), and a stuck backlog (the oldest
  queued batch and the last delivery both older than 15 minutes).
- Times are to the minute in the machine's locale (seconds dropped), and
  durations are in words. A renewal the ceiling cut short adds "Capped at
  8 hours from when this pause began."
- An explicit --session is named back, and its resume command carries it.
  The status screen closes with --resume --session and the id, or <id>
  when several sessions are paused.
- runPauseCommand's "status" action is gone. Its two tests moved to
  config-status.test.ts with the same intent. connectionStatusReport,
  connectionStatusLines and versionStatusLines are no longer called by
  --status. They stay for now (tested, and another step may reuse them)
  and can go in the clean-up step.

Audit cache keys unchanged: engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93.
Step 4 of the TUI redesign handoff. Bare `failproofai` prints the
redesign's launch screen once the local dashboard is listening: the
logomark (terminal only), the header, a DASHBOARD block (url, policies,
agents, cloud), at most one attention line, LINKS and the key hint.

- scripts/launch-screen.ts: a pure builder over screenKit(), facts in and
  lines out, plus the ✕ block for a start that fails.
- scripts/launch-facts.ts: reads the facts from files only. policies come
  from the same inputs as notEnforcingReason(). agents come from
  agents.selected, else the agents hooked at user scope, by display name,
  with detectInstalled() kept off the path. cloud follows config
  --status's precedence (environment, credentials, org, refused uploads
  over the credential file's claim), without its systemctl read.
- scripts/launch-ready.ts: readiness (decision D16). Next's "Ready in"
  line; a TCP probe of the bind address every 250ms after 5s without it;
  a 30s deadline that prints the screen with "○ starting"; and an exit
  before ready reported as one ✕ line with the real cause. For a taken
  port it drops Next's stack trace and says the port is in use. Server
  output is held until the start is decided, so the screen opens it.
- scripts/skew-log-filter.ts: makeNextStartupFilter() drops Next's four
  startup lines until the ready line, beside the skew filter, whose
  pass-through test is untouched.
- scripts/launch.ts: launch(mode, { screen, logo }). Audit's hand-off
  passes screen: false and keeps its own lines (LAUNCH-1); the bin passes
  logo: false after the first-run wizard ran (LAUNCH-8). FORCE_COLOR
  reaches the child only when stdout is a terminal (LAUNCH-M2). The
  exposed-bind warning is the screen's attention line and outranks "not
  enforcing"; with no screen it goes to stderr before the server starts.
  Dev mode keeps inherited stdio and prints its screen up front, marked
  starting.
- The audit tests that pinned launch("start") now expect the screen
  option, the value this change sets.

Judgment calls:
- docs is https://docs.befailproof.ai. docs/index.mdx is the deployed
  docs' home page; the /introduction page #461 pointed at moved to
  docs-old/ in #699 and has no redirect in docs.json.
- The shipped CLI cannot change its port (the bin rejects --port and
  launch sets PORT itself), so the port-in-use line says how to free it.
  Only the contributor scripts, which take --port, are told to pass
  another one.
- policies reads "N on from <id>@<version>", "N on from K packs" or
  "K sources", or "none on"; custom policy files are noted, never
  imported. agents names four and "and K more" past five. cloud gives
  half connections and refused uploads their own wording, so the row
  never says connected while nothing arrives.
- The security warning keeps all three consequences in one sentence, so
  its fix sits on the next line rather than inline.
- "The wizard drew the logomark" is read as maybeFirstRunConfigure()
  returning true. That also covers its two early refusals (sudo, an
  unsupported platform), which draw no art.
- renderLaunchBanner has no callers now. It is left for the step-8
  cleanup so this commit leaves tui.ts alone; renderBrandLogo and intro()
  are unchanged.
- The node:path and node:net imports are aliased, so the scope-hoisted
  CLI bundle renumbers and renames nothing outside the launch modules.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93).
…uage

`failproofai audit` now draws the 2026-10 progress screen: a bar per
agent with sessions to scan and running counts of what was found,
finished in place when the scan ends (decision D16). The sign-in,
--schedule, --no-schedule and --status screens move to the same
language.

runAudit
- RunAuditOptions.onProgress: ONE `discovered` event with every agent's
  transcript total (0 included) and the number of policies a replay can
  fire, then one `transcript` event per transcript - cache hit, scan,
  failed scan or skipped task alike - with its agent and hit counts.
  Optional: the dashboard and the scheduled run do not pass it, and the
  result is identical either way. A callback that throws is caught, so it
  can never count as a scan error.

The audit screen (src/audit/cli.ts)
- buildAuditScreen() is pure: header("Audit"), SCANNING HISTORY with
  "<n> agents, <m> policies", one row per agent (bar(30) plus "301 of 412
  sessions" or "✓ 1,284 sessions"), FOUND SO FAR with "would block" /
  "would warn" / "audit only", then the key hint. Finished, the same
  screen reads SCANNED HISTORY / FOUND, every bar done, and the summary
  replaces the key hint. While transcripts are still being listed the
  heading meta is the dashboard's first stage label.
- Live only on a TTY with colour, through the kit's new live() region:
  at most one frame per 100 ms (tokens.json), each ONE write in
  synchronized output. The old spinner wrote its cursor-up and its lines
  as separate chunks and advanced on a timer. Piped or under NO_COLOR, the
  finished screen prints once, with no escapes and no frames. Prose is
  fitted to a terminal, never to a pipe (D9); the bar gives up width
  before the counts do.
- FORCE_COLOR is no longer read, matching tui.ts (OPS-24).
- The hand-off is one ok line naming the audit URL, then the key hint:
  "✓ The audit is ready:  http://localhost:8020/audit" and "ctrl+c stop
  the dashboard". Audit prints no logomark itself; the launch("start")
  call is unchanged (its banner is the other branch's change).
- AUDIT_STAGES stays and still mirrors the dashboard's RunProgress; its
  comment now says the CLI draws real bars (D16) and uses the first label
  for the discovery phase. The drift test passes unchanged; only its
  header comment is updated.
- audit --help is untouched.

Kit (src/hooks/tui.ts), no new top-level names
- screenKit().live(out): a redraw-in-place region. Cursor hidden from the
  first frame and shown again on done, on exit and on SIGINT/SIGTERM;
  a signal is re-raised once the cursor is back, so Ctrl+C still stops
  the command exactly as before. The last frame is never truncated.
- repaint() takes an optional frame { lead, tail, cut } so a cursor mode
  rides in the same write as the frame.
- promptText({ field: true }): the redesign's field, "  email     ›
  value", with a refusal as a ✕ line under it, drawn in one write and
  cleared as soon as the value changes.

Sign-in and scheduling
- cli-login: header("Audit"), SIGN IN, fields, one ✓/✕ line per outcome;
  no intro() logomark and no spine (LAUNCH-10). Validator messages are
  sentences now.
- schedule-cli --status: header("Audit"), SCHEDULE kv rows on one column
  (● / ○ states), then one ▲ per distinct problem with its fix (daemon
  first, then digests). --schedule and --no-schedule state results as
  sentences; the daemon warning is a ▲ line on stderr.

Judgment calls
- KEPT, despite D6, the one line `audit --schedule` prints about what
  each report sends: "Each report sends finding counts, redacted example
  commands and this machine's name." as one ink3 line under the result.
  `reportsConsentedAt` records that a person READ that disclosure before
  anything leaves the machine, so it is part of the consent, not fine
  print. A test now pins it. The product owner will be told explicitly.
- "would block" / "would warn" go by what the policy does, not by
  severityForBuiltin, the score's heuristic, which files protect-env-vars
  and prefer-package-manager under warn although both deny: warn-* would
  warn; sanitize-* would warn too, because it acts on PostToolUse after
  the call ran (#669); every other builtin would block. The audit-only
  detectors get a third row, "audit only", instead of being dropped or
  misfiled (OPS-11). All three rows always show, so the block never jumps.
- "<m> policies" counts the builtins a replay can fire (34 today): the
  Stop-only require-*-before-stop gates are registered but never asked.
- The summary says "already covered by your policies", not "already
  blocked": an enabled warn-* policy warns rather than blocks.
- The empty-history ending no longer points at `policies --install`: the
  audit reads each agent's own history, hooks or not. It says "Run it
  again after using your agent:  failproofai audit".
- Counts carry thousands separators (1,284 sessions), like the summary.
- The onboarding audit (runPostSetupAudit) shares the screen; its own
  lines are restyled: no ✦ banner, "▲ None of this is enforced yet.  ·
  failproofai policies add FailproofAI/policies", "✕ The audit could not
  finish.  ·  failproofai audit".
- --no-schedule drops "your session is untouched" (D6); the session is
  still kept, as before.
- The sign-in fields have no key-hint line under them: promptText draws
  one row, and the placeholder hint stays on that row.

Tests: audit-progress.test.ts (the callback on real fixture transcripts
for claude and factory, cache hits included), audit-screen.test.ts (the
builder at fixed widths, plain and coloured, mid-scan and finished; the
piped, NO_COLOR and TTY wiring, with every frame one atomic write),
live() and the field prompt in the kit tests, and the sign-in and status
screens. Assertions on the old buildSummary copy, the old status labels
and a lowercase "running" are updated deliberately; nothing is deleted.

Audit cache keys unchanged: engineVersion=10c367f637740d67
detectorVersion=895c2fedece85e93.
Step 6e of the TUI redesign handoff.

backfill
- Re-sends only the agents failproofai traces (agents.selected, or every
  agent when there is none).
- `--agents a,b` narrows it to a subset:
  - a name that is not an agent is refused ("Not an agent")
  - an untraced agent is refused with the reference's backfillUntraced
    copy
  - so is any daemon/CLI version mismatch, because an older daemon reads
    every request as "rewind everything"
  Nothing is written in any of those cases.
- Without --agents the request names nobody, so the daemon applies the
  selection current when it acts, not a stale copy.
- Session counts come from the audit adapters, the same readers the
  audit uses. They know all twelve agents and count sessions; the old
  file survey knew seven and walked the wrong directory for three
  (OPS-1).
- Database-backed agents (Goose, OpenCode, Devin) say "all N sessions,
  whatever the window": the collector reads them by row and re-sends all
  of it. Hermes says it is not re-sent, because the daemon does not
  rewind its profile stores until they have a window (D7).
- The screens follow the reference: backfillDry (WOULD RE-SEND,
  traced only, "Not traced, skipped: …"), backfill (one ✓ line), and
  backfillUntraced.
- The result says when the data arrives ("over the next few minutes",
  watch with config --status) instead of implying flush --wait finishes
  it (OPS-3). It keeps the streams caveat and the stopped-daemon line
  (OPS-2).

config
- `--oss`: skip the connect step. A connection already present is left
  as it is (D4). It overrides FAILPROOFAI_CLOUD_TOKEN; only an explicit
  --token contradicts it, which is an error.
- `--agents a,b|all`: any of the twelve, validated before the wizard
  runs, and the agents question is skipped.
- Both are documented in config --help.
- The help index's config row now says what setup asks: "Connect to
  cloud or use open source, and pick agents to trace". This is the
  design's row, trimmed to fit 80 columns.

config --status counts agents found on this machine but not traced
("8 traced, 1 not traced, 3 not installed"), as config-flow.md asks.

harness list / add-path
- Drawn with the kit: a heading per agent with its count, rows of path
  and agent ids, one ✓ or ✕ line per result.
- An agent that is not traced gets a ▲ line, because the daemon starts no
  task for it, extra paths included.

Fixes (pre-existing, found while restyling):
- `backfill 6m` (no --since) silently ran the default 30 days. Every
  stray word is now refused, with "did you mean --since 6m" when it
  looks like a window.
- `config --token K --machine-label L` ran a rename and ignored the key
  (spec fix 9). With --token, --oss or --agents it is now the name to
  set up under.
- `config` ran full setup with an unknown option silently ignored. An
  unknown option or a stray word is an error now, and `config status`
  suggests `config --status`.
- backfill's not-connected error taught `config --token <key>`, a key
  on argv. It points at `failproofai config`.
- `harness add-path` claimed `harness list` shows what is captured. It
  reads the same file; the daemon's log is where rejections show up.

Tests:
- new: backfill-cli (11)
- updated to async: backfill-request
- harness-extra-paths: the ━━ rule pin becomes the UPPERCASE heading,
  plus untraced tests with their own temp home
- config-status: not-traced count
- cli-args e2e: --oss with --token, unknown agent, unknown option, the
  bare-word suggestion, help rows, `backfill 6m`

Full unit suite (8158) and e2e (341) green. ~/.failproofai/config.json
was checksummed unchanged across the run. Audit cache keys unchanged
(engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93).
After applying a backfill request, the collector manager rebuilt the
collector from the config read at that moment, then read the config
again to record what was running. An edit that landed between those two
reads was recorded as already running, so the next compare saw no change
and the edit was never applied.

This was latent on main. Setup v2 makes it routine: when `failproofai
config` re-adds an agent it writes the `added` backfill request first
and the selection right after, which is exactly that window. The
re-added agent stayed untraced until some unrelated edit cycled the
collector.

The fix records the config read BEFORE the build as what is running,
the same rule the config-change branch below it already documents ("What
was BUILT FROM, not a fresh read"). An edit landing between that read
and the build now costs one extra cycle instead of being lost.

agent_selection_e2e's
an_agent_re_added_on_a_running_daemon_never_resumes_its_stale_cursors
reproduces it. It failed 2 of 3 runs on an idle machine before the fix
and passed 6 of 6 after. cargo fmt, clippy -D warnings, and the
failproofaid crate's 190 tests all pass.
The disconnect result said a running failproofaid "keeps the key it
started with, so restart it to stop the current process sending". That
was true when the collector manager started once per daemon lifetime.
It now polls the collector config every interval, and that config
includes the ingest credential (CollectorConfig::ingest; is_enabled() is
ingest.is_some()), so the collector stops within one interval once the
key is gone. The restart advice sent people to do work that was not
needed, and it needs root.

The line now says the daemon stops sending within a few seconds. The
test that pinned the restart wording tested exactly this value, so it
is updated to pin the corrected one.
On a machine with `enabledPolicies` and no regex pack installed,
handler.ts registers those builtins itself: the migration shim. The
policies screen showed none of it, so it drew only the convention files
while thirty builtins enforced. config --status counted them ("30 on
from legacy builtins"), so the two screens disagreed. Found in a QA pass
of the merged screens.

The screen now has a BUILT IN heading, "N on, from before packs",
followed by the command that moves them into a pack. It uses the same
hasInstalledRegexPacks() test the hook path does. It is a count, not a
list of names, because #738 pins that this screen names no builtin
(cli-args e2e: the list never contains "block-sudo"). A machine like
that is no longer drawn as empty either.

Also: config --help's --machine-label row said "never runs setup". Since
6e, `--token K --machine-label L` sets the machine up under that name,
so the row now says so.
printReport is the shared presenter for the action commands that return
bare facts: flush, migrate, update and uninstall. It now draws the
2026-10 shape instead of the retired `title()` + indented-wrap one:
- the kit header names the command, with any meta after it
  ("Migrate · dry run")
- top-level lines start at column 0
- lines a module indented on purpose keep their indent
- a failed run (exit non-zero, printed to stderr) opens with ✕, and what
  follows is indented under it as the fix

Backticked commands still turn pink and lose their backticks. Wrapping
still happens before colouring, so widths stay right. Paths are never
broken, because wrap() only breaks at spaces.

Restyling the presenter, rather than each command, is the review's plan
for screens the design does not draw: "restyle the shared pieces in
place so most follow automatically".
Step 6f of the TUI redesign handoff: decision D2, "turn the agent back
on". `agents.selected` in config.json drives which agents `failproofai
config` hooks and which harnesses the daemon collects, but only `config`
wrote it, so:

- `policies --install --cli goose`, on a machine whose selection left
  Goose out, wired Goose, and the next `failproofai config` silently
  removed those hooks again.
- `policies --uninstall --cli goose` left Goose traced, so its sessions
  kept being collected and the next `config` put the hooks back.

Now:

- Installing an agent's hooks, at any scope, adds it to
  agents.selected (INTEGRATION_TYPES order), and to `seen` when
  detectInstalledClis() finds it. With no `agents` key nothing changes:
  every agent is traced already.
- Removing an agent's hooks at user scope or `--scope all` takes it
  out. With no `agents` key it writes `selected` = every
  INTEGRATION_TYPES id but the removed ones, `seen` =
  detectInstalledClis(). An empty selection is allowed.
- One line after the command's own output, built with screenKit().ok:
  "✓ Goose is traced again, so failproofai config keeps its hooks." /
  "✓ Goose is no longer traced, so failproofai config won't add its
  hooks back." Several agents read "A, B and C are ...". A quiet
  install prints nothing.

Covered: `policies --install` / `-i` (with --cli or the agent picker),
`policies add <name>`, `policies --uninstall` / `-u`, `policies remove
all`, and the dashboard's per-agent panel (app/actions/
install-hooks-web.ts, server side). `policies add <owner>/<repo>` and
the bare `policies add` picker install packs, not hooks. Untouched:
`failproofai config` (its own installHooks call writes no selection and
prints no note), `failproofai uninstall`, and project- or local-scope
removals.

The logic is one function, keepAgentSelectionTrue, in the new
src/hooks/agent-selection.ts. It is that module's only top-level name,
so the bundle gains nothing a policy or detector local can collide
with; the audit cache fingerprint is unchanged
(engineVersion=10c367f637740d67 detectorVersion=895c2fedece85e93).

Judgment calls:

- Opt-in, not default. installHooks takes `syncAgentSelection` in its
  options and removeHooks in its opts; bin's four call sites and both
  dashboard actions pass it. Default-on would need the wizard and the
  full uninstall to opt out (or a check on the `source` telemetry tag),
  would make every bare removeHooks() write config.json (manager.test.ts
  pins exactly three settings-file writes for --scope all, and none
  when there is nothing to remove), and would let any caller that does
  not isolate FAILPROOFAI_HOME rewrite a real config.json.
- A removal untraces every agent the command acted on, including one
  with nothing to remove: "No settings file found. Nothing to remove."
  is followed by the note. The command still said "not this agent",
  and leaving it traced lets the next `failproofai config` hook it.
  This includes the Claude Code default when no agent CLI is on PATH.
- An install traces only agents whose hooks were actually written, so
  `policies add <third-party pack policy>`, which wires no hooks,
  traces nothing.
- Adding an agent rewrites `selected` in INTEGRATION_TYPES order. The
  wizard saves it detected-first, but every reader re-sorts, so only
  the file changes. Ids this build does not know (a newer build's
  agents) are kept, after the known ones. `seen` is rewritten only when
  an agent is added to it.
- Plural notes say "their hooks" where the singular says "its hooks",
  alongside the plural verbs. "failproofai config" in the note goes
  through kit.cmd: pink with colour on, the exact text with it off.
- A config.json write that fails does not fail the command, because the
  hooks are in place. It prints a ▲ line naming the reason and the
  consequence instead of the ✓ note.
- `policies remove <name>` passes the flag too. Its only effect is on
  `policies remove all`, which removes hooks; a policy name takes the
  policy-only path, which never touches the selection.
- A quiet install still writes the selection; only the note is muted.
  No current quiet caller opts in.
- The dashboard panel shows errors only, no success messages, so its
  UI is unchanged and the note lands in the server log.
- Left alone: `failproofai update`'s Hermes migration. It rewrites
  hooks a profile already has (shell hooks to the native plugin) and
  installs for no new agent.

Consequences worth knowing:

- A project-scope install now traces the agent machine-wide, so the
  next `failproofai config` also gives it user-scope hooks.
- On a machine with no config.json, a user-scope removal creates one
  (defaults plus the selection), as `harness add-path` and the
  telemetry opt-out already can; detectLayout reads that home as
  current. Its first `failproofai config` then opens on that list
  (every agent but the removed one, undetected ones included) rather
  than on the detected agents.

Tests: __tests__/hooks/agent-selection.test.ts (32),
__tests__/dashboard/install-hooks-web-agent-selection.test.ts (3),
__tests__/e2e/cli/agent-selection.e2e.test.ts (3, through the real
binary). tsc and eslint clean; full vitest 391 files / 8179 passed;
e2e 18 files / 337 passed. A first full run on a loaded machine had 17
timeouts in unrelated files, which all passed re-run alone.
A failed flush prints the kit header, then ✕ and the module's sentence,
then its fix indented two columns, with no literal backticks. Also moves
the printReport CHANGELOG line from Fixes to Features, where a new look
belongs.
Two pack-lane screens still used the retired title/stack/emptyState/
nextStep helpers:
- `policies show <owner>/<repo> --releases`: the kit header names the
  repository (the id is case-sensitive, so it is never uppercased), with
  a RELEASES heading and its count. The releases stay a column table,
  since the kit's rows are two-column. The "—" note is grey, and the
  install command is pink.
- `policies add` / `remove` with nothing installed: the same three
  doors the policies screen's own empty state offers (ours, anyone's,
  look first), as indented pink commands.

The facts the tests pin are unchanged: the release count, "no releases",
"— means the release did not record it." and "No policies are installed
yet."
Removing every agent's hooks (policies --uninstall, after 6f) saves an
empty selection. An unattended `failproofai config` then fell back to
all twelve agents, because the fallback for an empty start was meant
for a brand-new machine with nothing detected. It put back every hook
the user had just removed.

A saved answer now stands even when empty. The run hooks nothing,
because an empty list is not something to hand installHooks, and the
AGENTS line reads "▲ Tracing no agents · failproofai config". The
interactive picker still needs at least one agent.
They printed bare lines with no heading. They now go through printReport
like the other action results: the kit header names what ran (Connect,
Disconnect, Rename), and a failure opens with ✕ with its fix indented
under it. The modules' own lines are unchanged, so their tests are too.
…guage

Step 7b of the TUI redesign. Every error now prints as the design's
notice: `✕` and what went wrong, then the fix on its own line with the
command in pink. The remaining one-off screens move to the screen
language: the uninstall question (D17), the banners printed before a
command, the publish done screen and `jev status`.

Errors
- One printer for every CliError, bin's and the manager.ts ones it
  carries: `Error: <message>` becomes `✕ <line 1>`, every later line a
  fix line indented two, and a `backticked` command painted pink with the
  backticks dropped (new screenKit().notice). Colour is decided from
  stderr; exit codes are unchanged. Written with process.stderr.write and
  drained through exitAfterFlush, because Bun paints console.error red
  on a terminal, a colour from outside the palette. "Unexpected error"
  stays plain: that path cannot trust the install enough to load the kit.
- Q4, the design's words: "There is no command called 'status'." then
  "Did you mean  failproofai config --status?", from the new
  src/hooks/did-you-mean.ts. A table of words people type comes first
  (status, pause, resume, login, setup, install, logout, upgrade, hook,
  dashboard and more). Then the nearest subcommand by spelling: a swap of
  two letters counts as one edit, and a unique prefix wins, so `back` is
  backfill, not `pack`.
- Q6, a distance cut-off: two letters must match exactly, up to four
  letters one edit, longer words two. `install` suggests `policies
  --install` (the table is asked before spelling), `-i` no longer
  suggests audit, and a word close to nothing gets no guess and points
  at `failproofai help`. Flag suggestions are whole commands
  (`failproofai --version`), and --hook is never offered.
- Q5: `--cli` in policies --install, --uninstall, add and remove goes
  through one helper reading INTEGRATION_TYPES: "Not an agent: claud
  (did you mean claude?)" or "Missing agent name after --cli.", then all
  12 ids on two rows.
- Every other bin error reworded per the inventory: full stops, two
  spaces around commands, label-shaped first lines rewritten (policies
  show, --custom), `policy add` is `policies add`, the remove lane's
  missing --scope gains `all`, the add|remove lane's unknown flag points
  at `failproofai policies <add|remove> --help`, --version's extra
  argument now says `failproofai help` like --help's, and a missing
  --token value points at FAILPROOFAI_CLOUD_TOKEN, never argv. The
  unreachable "Unknown policies subcommand" branch is deleted (ERR-11).
- The --hook usage error is the notice shape written out by hand, with
  no colour (see the audit-cache note below).
- manager.ts: fix lines gain backticks so their commands paint. The two
  errors it printed itself (`Error: …` for a custom-policy file that
  will not load or registers nothing) go through the notice too.

Uninstall (D17)
- ONE question: `Continue? y/N`, from the new screenKit().confirm. It
  reads raw keys like the pickers; only y or yes, then Enter, is a yes,
  and Enter alone, Esc, Ctrl+C, Ctrl+D or closed input is a no. Above it,
  a plan read off what the steps do: "This removes failproofai's hooks
  from N agents and the failproofaid service (needs sudo).", the agents,
  "Also resets enabled policies, custom policy paths and policy
  parameters in policies-config.json." (removeHooks(…, "all") does that),
  and what is kept, or what ~/.failproofai holds with --purge.
- Yes is yes to the whole plan, the service included, which is what
  --yes always meant. The second "remove the service too?" question is
  gone; keeping the daemon while removing hooks is `failproofai policies
  --uninstall`. --yes still asks nothing and removes the service, and
  without a terminal and without --yes it still refuses with exit 1.
- The outcome is ✓ / ✕ lines under header("Uninstall"). removeHooks
  gains an opt-in `silent`, so its own `policies --uninstall` rows no
  longer land between the question and the outcome.

Banners before a command (one state line and one fix line each)
- fp-reset: a newer layout is a ✕, since it stops the command. The
  stale-home reorganisation is a ✓ with what was rebuilt and what was
  kept, a ▲ for undelivered batches and a ✕ for a failed step. The three
  daemon self-heals and the version-skew hint are ▲ plus a fix, with
  "denies every tool call" kept on one line. The kit is loaded with
  import() inside checkLayoutForCli, so handler.ts's hook path (it
  imports fp-reset) never loads the renderer.
- The `--token` warning, and the aborted-setup hint in
  onboarding-attempt.ts.

publish and jev status
- publish: header("Publish"), a ✓ per side effect (git commit, bundle,
  created repository), "✓ Published <id>@<version> with N policies."
  (the repository and tag are named only when they differ from the id
  and version), then "  Anyone can install it:  failproofai policies add
  <repo>". The private-repo variant puts a ▲ in the install line's place,
  and the private refusal is a ✕ notice.
- jev status: header("Jev"), a status row, then provider and model,
  endpoint, mode, timeout, key, config with its permissions, and what Jev
  may clear; any ▲; the ACTIVITY block; the next commands last. There is
  no "last test" row (D16: nothing records one) and no key tail (nothing
  in this module prints the key). --json is untouched.

Root-required copy
- update: an install that needs root says "✕ failproofaid <v> was not
  installed: root privileges are required. The previous daemon is
  untouched." and "Run `sudo -v` and then `failproofai update` again",
  instead of "re-run failproofai config" plus a unit file to paste.
  printReport paints a leading state glyph by role.
- uninstall: a service it could not remove gets "Remove it as root:",
  the exact sudo commands, and where to check it.

Audit cache keys unchanged: engineVersion=10c367f637740d67
detectorVersion=895c2fedece85e93. The first build moved engineVersion:
Bun orders modules by where each import() first appears, and a kit
import in the --hook usage-error branch, the first import in bin,
pulled tui.ts ahead of builtin-policies.ts. Its top-level `ctx` then
renamed every policy's `ctx` parameter to `ctx2`. That branch now
imports nothing, and the builtin-policies module is byte-identical to
the base build.

Tests updated on purpose (copy or behaviour this change sets)
- cli-args.e2e: "Unknown command: X" becomes "✕ There is no command
  called 'X'.". The "always offers a runnable suggestion" case becomes
  "suggests nothing for a word close to no command", because Q6 changes
  exactly that. The closest-flag case uses --versoin, since
  --unknownflag now gets no guess.
- help-index: the unknown-topic error's new words. The index check now
  looks for those words; checking the old ones would test nothing.
- uninstall-cli: the dry-run and cancel lines, the plan prefix, and the
  "who decides the daemon's fate" block rewritten for one question (the
  two keep-the-service cases now expect removal on yes, per D17).
- publish-command: "Published X to Y at tag Z." becomes "Published X
  with N policies." in seven places. The short form is printed only when
  tag and version agree, so the pin still guards that, and the comments
  say so. "3 assets attached" is dropped (the uploads are asserted
  directly), the install line has its new words, and "PRIVATE" is
  "private".
- jev-cli, jev-cli-status-reviewable, jev-cli-status-stats: "Jev is off"
  becomes the status row "○ off: there is no …". jev-cli-cloud: the
  separate-steps check reads one-line steps.
- fp-reset kept every pinned phrase; the test that wants what was
  rebuilt AND what was kept is why the rebuilt line stays.

New tests: did-you-mean (table hits, typos, the cut-off, silence); the
kit's notice() and confirm() with colour on and off; the D17 question's
exact wording and colour; the banners' shape and colour; the update
root-required lines; the publish done screen (side effects, a
differing tag, colour on a terminal); the jev status layout and colour;
the --token warning's shape; and the --cli errors and the printer's
shape end to end.

Judgment calls
- D17: yes to the one question also removes the daemon service, as the
  design draws it ("stops the daemon") and as --yes does. The old
  keep-the-daemon question has no replacement prompt; `policies
  --uninstall` is the hooks-only path.
- D17 wording names the agent count and the agents, the
  policies-config.json reset, "(needs sudo)", and what is kept or
  purged. The reference's "your settings and history are kept" was not
  true.
- --yes prints no plan (its ✓ lines say what was done). A dry run and
  the no-terminal refusal still show it.
- confirm() is built on raw keypresses, not readline.createInterface,
  whose terminal mode needs a full output stream and ignores Esc.
- `auth` maps to `failproofai audit` through the table, keeping the
  e2e-pinned behaviour (auth was the audit reminder's sign-in), while
  `login` maps to `failproofai config` as asked.
- Table entries beyond the owner's list: doctor and whoami (config
  --status), signin and connect (config), logout and disconnect (config
  --disconnect), enable and add (policies add), disable (policies
  remove), list and ls (policies), upgrade (update), version
  (--version), hook and hooks (help hook), dashboard, start and open
  (bare failproofai). setup, configure, policy and pack already run as
  aliases; they are in the table only so a typo of one matches.
  Ambiguous words (remove, delete, init) are left out.
- Flag table: --status, --pause, --resume, --token, --connect,
  --disconnect, --install and -i, --list, --all. -u and --uninstall are
  left out: they could mean policies --uninstall or uninstall.
- A far-off word's fix line is "Run  failproofai help  to see every
  command." rather than nothing at all.
- "Not an agent: claud (did you mean claude?)" mirrors the pack lane's
  existing wording.
- The --hook usage error is uncoloured text (audit-cache constraint
  above).
- Banners: a newer layout is ✕, the reorganisation ✓, the rest ▲. The
  condition-failed self-heal drops its `systemctl status` pointer to
  keep one fix line.
- Left as they are: layoutBlockerForScheduledRun and
  layoutWarningForHook (journal and hook-stderr log lines with a
  "[failproofai]" prefix); the "Not set up yet" and "Setup is already
  running" hints, which live in configure-wizard.ts (not mine to touch);
  the backfill branch's argument errors (backfill is owned elsewhere;
  they still go through the new printer); jevLines in
  cloud-connection.ts, embedded in the wizard's DONE and --connect,
  which belongs with the --connect screen; publish's other refusals and
  dry-run screen; and installDaemonService's reason text, which the
  wizard prints and classifies needs_root by. update renders its own
  copy instead.
- publish: "That names the commit it was built from." and the
  "policies show … (look first)" line are dropped as fine print (D6).
  A Jev-only pack shows no policy count and keeps its rollback reminder
  as a ▲.
- jev status: a status row added so every state answers "is Jev
  running?" in one place; provider is merged with model and config with
  permissions; modeLine's wording is kept (shared with setup and pinned
  by tests) rather than the reference's sample copy; refusedNextStep's
  leads are kept verbatim (shared with `jev test` and pinned);
  NO_JEV_CHECKS_PROBLEM prints as one ▲ line because it is a shared
  constant.
- printReport colours a leading glyph. No backfill line starts with
  one, so backfill's output is unchanged.
- The hard-coded \x1B[33m duplicate-scope warning in installHooks is
  pre-existing `policies --install` output and is left for that screen.

Gates: tsc clean; eslint clean on every touched file; the full e2e
suite passes (twice). Full unit suite: the first run failed only the two
activity-store rotation tests (hook-activity-jev, semantic/jev-stats),
which timed out at 5 s and pass alone. A run after the final change, at
load average ~150 from other work on the machine, timed out 29 tests in
16 files this change does not touch. All 469 tests in those files pass
when re-run with a longer per-test timeout.
The install and removal results were bare console lines, and the
duplicate-scope warning hard-coded a yellow escape. That escape printed
into pipes and under NO_COLOR, which spec fix 21 calls out by name.

Now:
- install: one ✓ per agent ("Claude Code hooks installed (12 events,
  user scope)"), its settings path as a kv row, then the binary or the
  npx command, plus the project-scope note that the file can be
  committed
- duplicate scopes: one ▲ line with the uninstall command as its fix
- pack fetch failure: ▲ with the add command
- "Enabled N policies", custom paths, and pack policy toggles as ✓ / kv
  lines
- removal: ✓ lines for "no settings file", "no hooks found", "removed N
  hooks" and "every scope", with kv/indented paths, all still through
  7b's `silent`-aware say()

Phrases the tests pin are kept inside the new lines ("No settings file
found", "No hooks found", "Custom hooks path cleared"). Two pins on pure
shape are updated: `Binary:` is now a kv row, and the old "Warning:
Failproof AI hooks are also installed" is now the ▲ line. "Validated N
custom hook(s)" is untouched: CLAUDE.md's smoke test greps it.
…key on argv

`config --machine-label` on an unconnected machine said to run
`config --connect <url> --token <key>`, which puts the key in shell
history and the process list, the thing --token's own warning warns
about. It now points at `failproofai config`, the way backfill's
not-connected error does since 6e.
7b restyled `jev status`. The other jev result screens still drew with
the retired helpers: title() with right-aligned meta, `━━` rules,
gutter warnings and two-line next steps. They now go through five local
adapters that map each old helper to its 2026-10 counterpart:
- the header names the subcommand and carries its outcome as meta
  ("Jev test · ok · 412 ms"), the way printReport does for action
  results
- the old rows become the kit's kv rows
- a note is wrapped grey prose
- a warning is ▲ with the rest indented under it
- a next step is its lead and the command on one line, or the wrapped
  lead with the command alone under it when the two would not fit, so a
  command is never split (D9)

Every text is unchanged, so all 241 jev tests pass as they were. The
calls keep stack()'s grouping, which is a layout joiner, not part of the
old look.

Left as is, a follow-up: the jev copy suggests `failproofai config
--token <key>` in eleven places, five pinned by tests. It is pre-existing,
and --token is a real flag, but config's own warning prefers
FAILPROOFAI_CLOUD_TOKEN.
Step 8 of the TUI redesign handoff. Every screen now draws with
screenKit(), so 335 lines of tui.ts have no caller left:
- the intro/outro/step/stepOpen spine and its glyphs (BAR, BAR_END,
  STEP_ACTIVE, STEP_DONE) and writeLines
- the old help renderers: helpScreen, helpHeading, helpBlock,
  helpEntries, helpColumn, helpOptsFor, HELP_COLS and their spec types
- renderLaunchBanner, renderBrandLogo (with MARK and TAGLINE), brandAnsi,
  danger, bullets
- the unused CHECK_ON, CHECK_OFF, CARET, ANSI_BOLD and ANSI_DIM

Still used, so kept: title/rule/note/warning/table/stack/emptyState and
chip, which the Jev checks section and a few pack-lane screens still
call.

The audit cache key hashes the bundle's policy and detector bodies, and
removing a top-level name can rename a local inside them. So the
removal was tried first in a throwaway worktree and fingerprinted, then
applied here and fingerprinted again: engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93, unchanged both times.

Tests: those of the removed helpers go. The three logomark tier tests
(256-colour cube, 24-bit, monochrome on 16 colours) are ported to
screenKit().logo(), which draws through the same renderLogo. The rule
test no longer needs brandAnsi to see that it is painted. Two doc
comments that named removed helpers are updated.

Gates: full unit suite 8244 passed. Four timing tests failed at load
average ~150 from other sessions, and all 45 tests in those files pass
on their own. e2e: 347 passed. ~/.failproofai/config.json checksummed
unchanged.
Merging several commits left two Fixes headings and a blank line inside
one. Now there is one Fixes list. Features run in reading order: the
look itself, then each surface, then config v2, agent tracing and the
daemon. The rename and empty-selection fixes, which had no lines, get
them, and the connect/disconnect/rename presentation joins the
action-results line.
…e rest

7b settled how a refused argument reads: the sentence ends with a full
stop, and a suggestion puts two spaces before the command ("Did you
mean  failproofai config --status?"). The argument errors config and
backfill added in 6e did neither, and their fallback said "Run … for
usage" where the rest now say "See its options: …".

@hermes-exosphere hermes-exosphere left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hermes found blocking issues that should be addressed.

High: Offline re-selection can upload sessions from the untraced interval

  • Rule: SEC-001
  • Location: src/hooks/backfill-request.ts:139
  • Evidence: requestFreshStart returns without recording an added rewind whenever collecting is false (src/hooks/backfill-request.ts:138-141). The wizard passes false when no ingest key exists or session collection is disabled, then still persists the newly re-selected agent (src/hooks/configure-wizard.ts:1034-1035,1247-1249). A later connection writes the ingest credential and enables sessions (src/hooks/cloud-connection.ts:308-319), so the daemon resumes that agent's retained cursor and can send transcripts created while it was untraced. The added test explicitly pins the no-request behavior for open-source and sessions-off machines (agent-selection-retrace.test.ts:130-133).
  • Required change: Persist a durable per-agent fresh-start marker whenever an agent is re-selected, even while offline or sessions are disabled. Consume it atomically before the first collector task starts after collection is enabled, then clear it. Add an end-to-end test covering unselect, offline reselect, later connect, and verification that the prior cursor is not resumed.

@hermes-exosphere hermes-exosphere left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hermes found blocking issues that should be addressed.

Review coverage was incomplete, but the concrete blocking findings below are sufficient to request changes.

High: Offline re-selection skips the cursor reset

  • Rule: SEC-001
  • Location: src/hooks/backfill-request.ts:139
  • Evidence: requestFreshStart() returns without writing an added request whenever collecting is false (src/hooks/backfill-request.ts:139). Both callers then persist the agent as selected (src/hooks/agent-selection.ts:136-138 and src/hooks/configure-wizard.ts:1247-1249). A user can disconnect, re-select an agent, use it while offline, then reconnect; the daemon finds no fresh-start request and starts that selected collector with its old cursor (crates/failproofaid/src/main.rs:405-414), uploading events created during the untraced interval.
  • Required change: Persist a durable per-agent fresh-start marker whenever an agent is re-selected, including offline. Consume it before starting collection after reconnect; do not use the current 10-minute added request TTL for this deferred privacy boundary.
1 advisory finding
  • High/High Claimed requests are abandoned after a daemon crash — The daemon renames the only request to the fixed .claimed path (crates/failproofaid/src/backfill.rs:473-478) and only ever reads the canonical request path on later manager iterations (take() at lines 467-469). If the process is killed after the rename and before read_claimed() removes and applies it, the .claimed file is never recovered. A normal backfill is silently lost; for an added request, the persisted selection can subsequently restart collection from stale cursors and upload data from the untraced period. (crates/failproofaid/src/backfill.rs:477)

…ntity changes

Root cause: PolicyStore's rollback guard keeps `server_high_water`, the
highest deployment the server has offered, for the life of the process.
The maintenance lane re-reads credentials on every tick so a key or org
change applies without a restart, but the floor was never tied to the
identity. The server numbers deployments per (org, machine). After an org
switch, a re-registered machine or a fresh enrolment, the new identity
legitimately answers deployment 0. The daemon refused it ("deployment
rollback from N to 0 is not allowed") until it restarted, and the previous
org's active.json kept enforcing.

Fix:
- PolicyStore::reset_high_water() clears the floor.
- The maintenance lane hashes each resolved client's identity: SHA-256 over
  the length-prefixed normalised URL, machine id and token. Only the digest
  is kept. When the identity changes (None->Some, Some->None or
  Some(a)->Some(b)), the lane resets the floor. A credential resolution
  error is not a change. Until the new identity's first reconcile succeeds,
  the floor is reset before each poll. Otherwise a failed first poll lets
  repair re-apply the old desired-state.json and raise the floor again.
  Within one identity the replay guard is unchanged.
- Enrolled -> not enrolled calls PolicyStore::deactivate(), which removes
  desired-state.json and then active.json. Removing only active.json, as the
  CLI's --disconnect does, was undone on the next tick: repair rebuilt it
  from the leftover desired state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chhhee10 and others added 2 commits October 14, 2026 13:54
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Root cause: Codex 0.129 and later runs a hook from ~/.codex/hooks.json only
once a person has approved it in interactive codex ("Review hooks"), which
stores the hook's identity hash in the user's ~/.codex/config.toml under
[hooks.state."<path>:<event>:<group>:<handler>"]. failproofai wrote its Codex
hooks to hooks.json and never dealt with trust, so headless `codex exec`
skipped every one of them: neither local nor cloud policies enforced, while
`failproofai config` printed "1 policy is enforcing" and `config --status`
agreed.

Codex treats hooks from its SYSTEM config layer (/etc/codex/config.toml on
Linux and macOS) as managed and runs them with no hash. So, for user scope:

- With root (the sudo `failproofai config` already primes for the daemon, or
  a passwordless `sudo -n`) and Codex >= 0.129, the hooks are written there as
  TOML hook tables, same events, commands and timeouts as hooks.json, inside a
  `# >>> failproofai managed hooks >>>` block. Every other byte of the file is
  kept, the original is backed up once to config.toml.failproofai-backup, a
  new file is 0644, and a file whose other `hooks` definitions would make the
  merge invalid TOML is left alone. failproofai's entries leave hooks.json:
  Codex runs every layer's handlers, so a trusted copy would run twice.
- Without root, on Windows, or with a conflicting file, hooks.json as before.
  An older Codex runs it unasked and needs nothing more.
- Uninstall (`failproofai uninstall`, `policies --uninstall`, unticking Codex
  in `failproofai config`) removes the block, restores the exact original
  from the backup when nothing else changed, and deletes a file failproofai
  created.

`config` and `config --status` now ask whether Codex runs the hooks: from the
system config, or from hooks.json only when every hook's trusted_hash equals
the hash Codex computes for it now (the normalized-identity SHA-256, checked
against Codex 0.162.1's own hooks/list for all 20 entries). Otherwise Codex is
not counted as enforcing, and both say how to fix it.

FAILPROOFAI_CODEX_SYSTEM_CONFIG overrides the path (`off` for none); both
vitest configs set `off` so no test can rewrite the real /etc/codex.

Verified in an fp-testbox container (codex 0.162.1) with a blocking cloud
policy: plain `codex exec` was blocked with no trust step; without sudo the
warning showed, Codex was not counted, and the command did run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@hermes-exosphere hermes-exosphere left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hermes found blocking issues that should be addressed.

High: Offline re-selection can upload sessions recorded while the agent was untraced

  • Rule: SEC-001
  • Location: src/hooks/configure-wizard.ts:1262
  • Evidence: requestFreshStart is called with collectingAfter && sessionsOn at line 1262, so re-selecting an agent while no ingest credential exists writes no added request; the new selection is nevertheless persisted at line 1264. After a later reconnect, that agent is already in the saved baseline and therefore produces no fresh-start request, so the daemon resumes its pre-deselection cursor and uploads sessions written during the untraced interval.
  • Required change: Persist a per-agent fresh-start obligation across disconnected/OSS runs, or safely clear that agent's cursors while no collector is active. Consume the obligation before starting collection after a later reconnect.

High: Merging a scoped backfill widens a re-added agent's upload window

  • Rule: SEC-001
  • Location: src/hooks/backfill-request.ts:80
  • Evidence: When an added request for one agent is pending, mergeBackfillRequests unions its agents with a later scoped user request and converts both to the user's sinceMs window (lines 80-85). The included test explicitly produces user(..., ["goose", "claude"]) from added(["goose"]) plus a Claude-only request. The daemon applies that user window to every named agent, so Goose can resend a much longer history than its seven-day re-add window although it was not named by backfill --agents claude.
  • Required change: Represent re-add resets independently from user backfills in the on-disk protocol, and have the daemon apply each agent set with its own window during one collector restart rather than collapsing them into one request.
1 advisory finding
  • Medium/High A claimed backfill request is abandoned if the daemon crashes — take renames backfill-request.json to the fixed .claimed name (lines 467-478), then read_claimed deletes it before the caller applies the rewind (lines 481-484). A crash after the rename leaves only .claimed; subsequent ticks inspect only the original path, so the requested backfill is never applied. A crash after deletion similarly loses an accepted request before apply_backfill. (crates/failproofaid/src/backfill.rs:477)

@hermes-exosphere hermes-exosphere left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hermes found blocking issues that should be addressed.

Review coverage was incomplete, but the concrete blocking findings below are sufficient to request changes.

High: Merging a user backfill with a re-add widens the re-added agent's replay window

  • Rule: SEC-001
  • Location: src/hooks/backfill-request.ts:73
  • Evidence: mergeBackfillRequests converts any mixed request into kind: "user", takes the oldest sinceMs, and unions the agent lists (lines 73-87). Thus a pending backfill --agents claude --since 30d followed by re-adding Goose becomes a 30-day user replay for both Claude and Goose. The re-add path exists to discard Goose's stale cursor and use the default seven-day window, so Goose can upload sessions from its untraced period.
  • Required change: Represent replay intent per agent (or keep separate pending user and added requests) so an added agent is never folded into a user-selected historical window.

High: Offline re-selection can later upload sessions recorded while untraced

  • Rule: SEC-001
  • Location: src/hooks/configure-wizard.ts:1262
  • Evidence: The wizard writes an added reset only when a collector is active at that moment (line 1262). After disconnecting, an agent can be removed and reinstalled via a hook command; its selection is restored but requestFreshStart writes nothing because no ingest credential exists. A later config --token sees no newly added agent and also writes no reset, so the collector resumes the old cursor and sends the intervening offline sessions.
  • Required change: Record durable pending fresh-start intent whenever an agent is reselected, even while offline, and consume it before enabling ingest on a later reconnect.

High: Disconnect can race an in-flight Cloud policy poll and reactivate policies

  • Rule: SEC-001
  • Location: src/hooks/cloud-managed-policies.ts:166
  • Evidence: Disconnect deletes active.json and desired-state.json (cloud-managed-policies.ts lines 166-178), but the daemon may already have loaded credentials. Its maintenance loop then calls poll_once with that in-memory client (cloud_client.rs lines 450-470); a successful response reconciles and rewrites both files (lines 508-521) after deletion. Subsequent unenrolled iterations still repair the cached active deployment unconditionally (lines 473-478), leaving Cloud policies enforced after config --disconnect reports success.
  • Required change: Serialize disconnect with reconciliation or persist a disconnect generation/marker that the daemon checks immediately before reconciliation and before cache repair; gate repair on that marker rather than relying on deletion alone.
1 advisory finding
  • Medium/High A daemon crash after claiming a backfill silently loses the request — take renames the only request to .claimed before it is applied (lines 467-469); read_claimed deletes that file before returning the request (lines 481-485). A crash after either step but before apply_backfill leaves neither the original request nor recovery logic on restart, so the requested replay does not occur. For an added request, collection can then resume the stale cursor after restart. (crates/failproofaid/src/backfill.rs:467)

Round 4 of 5. If the next review still finds something blocking, I will summarize what is left, withdraw this change request, and stop reviewing this pull request until someone asks me to start again.

Still open:

  • F1 Merging a user backfill with a re-add widens the re-added agent's replay window (src/hooks/backfill-request.ts) — open since round 1
  • F2 A daemon crash after claiming a backfill silently loses the request (crates/failproofaid/src/backfill.rs) — noticed at round 2, on code that had not changed since the round before, so it never blocked
  • F3 Offline re-selection can later upload sessions recorded while untraced (src/hooks/configure-wizard.ts) — open since round 3
  • F4 Disconnect can race an in-flight Cloud policy poll and reactivate policies (src/hooks/cloud-managed-policies.ts) — open since round 4

If one of these is not worth fixing, @hermes-exosphere dismiss <id> [reason] waives it for the rest of this pull request and gives the review another round.

@@ -163,16 +164,19 @@ export function resolveManagedPath(root: string, candidate: string): string {
* Returns true when a manifest was actually removed.
*/
export function clearActiveCloudManagedPolicies(): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hermes — High/High (SEC-001): Disconnect can race an in-flight Cloud policy poll and reactivate policies

Disconnect deletes active.json and desired-state.json (cloud-managed-policies.ts lines 166-178), but the daemon may already have loaded credentials. Its maintenance loop then calls poll_once with that in-memory client (cloud_client.rs lines 450-470); a successful response reconciles and rewrites both files (lines 508-521) after deletion. Subsequent unenrolled iterations still repair the cached active deployment unconditionally (lines 473-478), leaving Cloud policies enforced after config --disconnect reports success.

Required change: Serialize disconnect with reconciliation or persist a disconnect generation/marker that the daemon checks immediately before reconciliation and before cache repair; gate repair on that marker rather than relying on deletion alone.

@hermes-exosphere

Copy link
Copy Markdown
Contributor

I have spent 5 rounds of review on this pull request and I am still finding things to block on. At that point I am no longer the useful reviewer here, so I am standing down and leaving the decision to a person.

I have withdrawn my change request, so I am no longer blocking this pull request. I have also stopped reviewing new commits on it.

What I last reviewed: b76a2f5604ed.

Still open:

  • F1 Mixed backfill requests widen a re-added agent's replay window (src/hooks/backfill-request.ts) — open since round 1

  • F2 A daemon crash can permanently abandon a claimed backfill request (crates/failproofaid/src/backfill.rs) — noticed at round 2, on code that had not changed since the round before, so it never blocked

  • F3 Offline re-selection does not preserve the required cursor reset (src/hooks/configure-wizard.ts) — open since round 3

  • F4 Disconnect can still race a policy poll and reactivate Cloud policies (src/hooks/cloud-managed-policies.ts) — open since round 4

  • @hermes-exosphere dismiss <id> [reason] waives one of these for the rest of this pull request and starts me again.

  • @hermes-exosphere review [focus] starts over from a clean slate, with the waivers kept.

  • @hermes-exosphere reconsider [reason] asks for one more decision on what is here now.

None of this is a judgement that the findings above are wrong. It is a judgement that another round of me is not what will settle them.

@hermes-exosphere
hermes-exosphere dismissed stale reviews from themself October 14, 2026 12:40

Hermes spent 5 rounds on this pull request without converging and has stood down. This change request is stale and should not block the merge.

chhhee10 and others added 6 commits October 14, 2026 13:37
Lockfiles take main's (#901) newer patched versions; package.json keeps
this branch's version, next ^16.3.8 and the source-map-js override on top of
main's dependency bumps. CHANGELOG keeps both sections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1.0.11-beta.1 is already on npm (next), so this branch's beta is beta.2.
Package, Cargo workspace and Hermes plugin versions move together; the
CHANGELOG section is renamed and gains the disconnect, reconnect and
org-switch fixes. The Codex headless proof reads the version from
package.json instead of pinning it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chhhee10 added a commit that referenced this pull request Oct 15, 2026
Brings main (#901), the 1.0.11-beta.2 bump and the factory fixes
(Codex system-config hooks, disconnect, connect reconcile, org-switch
boundary, rustls) into the OTel branch.

- filetail::new_cursor: the org boundary check runs first (a file last
  modified before the switch gets no cursor), then the OTEL resume cutoff
  places a handed-back Codex rollout at EOF, then the since-days window.
- lib.rs: both org_boundary and otlp modules.
- Tests: kept both the org-boundary and the resume-after/OTLP config tests.
- Cargo.lock: feat/tui-ux's patched versions, plus axum for the relay.
- CHANGELOG: one 1.0.11-beta.2 section with #881 and #880 entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… into feat/tui-ux

# Conflicts:
#	CHANGELOG.md
@chhhee10

Copy link
Copy Markdown
Member Author

Folded into #880, which now targets main and carries all of this PR's commits (TUI + config v2, the daemon policy-floor fix and OpenTelemetry) as one PR. Branch kept.

🤖 Generated with Claude Code

@chhhee10 chhhee10 closed this Oct 15, 2026
NiveditJain pushed a commit that referenced this pull request Oct 15, 2026
…899 + OTel) (#880)

* feat(otel): add reversible opt-in agent setup and wizard step

* feat(otel): add durable loopback relay and per-agent transcript lanes

* docs(otel): link changelog to draft PR 880

* fix(ci): update vulnerable existing build and SDK test dependencies

* docs(otel): clarify metrics acknowledgement in paired server

* feat(tui): palette for the 2026-10 screen language

Step 1 of the TUI redesign handoff: add the redesign's colour roles to
HUES and expose them through paint() and brandAnsi(), so no caller ever
hard-codes an escape.

- ink3 (#767f8b, labels, heading meta, key hints) and track (#3e434c,
  the empty part of a progress bar) are the designed hex at 24-bit and
  the SGR 2 dim attribute below it.
- err (#f07178) is the red for failed lines: 203 at the 256 tier, 31 at
  the basic tier.
- ink2 is the identity: descriptions keep the terminal's own foreground.

Deviations from the handoff, as agreed in the review (D12):
- The handoff suggested fixed 256 greys (248/243/238) and a basic-tier
  bright black (90). A fixed grey fights every theme that is not ours,
  and 90 is the background colour in Solarized Dark, so ink3 and track
  stay the dim attribute below 24-bit, as dim already does.
- The designed ink2 #a7adb6 is tuned for dark terminals and measures
  about 2.2:1 on a light one. Nothing detects the background, so
  descriptions are never painted.
- err uses 203 instead of the suggested 204: 204 is nearest by distance
  but differs from the brand pink (168) only in its red channel and
  reads as pink at that tier.

Audit cache keys are unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93): the roles are properties on existing
objects, so the bundle gains no top-level name.

* feat(tui): building blocks for the 2026-10 screen language

Step 2 of the TUI redesign handoff: header, head, rows, kv, cmd, the
●/○ state glyphs, ok/caution/fail, keys, bar, logo and helpPage, which
match reference/screens.js FP.primitives. They are pure builders over
RenderOpts, colour on or off, so every screen built from them can be
asserted at any width without a pty.

Deviations from the handoff, as agreed in the review:
- Nothing copyable is ever cut (D9). Commands, URLs, paths, ids and kv
  values are left whole. Only prose (row descriptions) is shortened, with
  `…`, and only when a caller sets `fit` for a terminal; piped output is
  never cut.
- Columns are derived from the block, with a guaranteed two-space gap,
  instead of the reference's fixed widths. The longest real policy name
  (require-no-conflicts-before-stop) is exactly the 32 columns the design
  fixes, so a fixed column runs it into its description.
- The logomark returns nothing below its minimum width. The old ▮▮
  one-liner is retired with that mark.
- Without colour, a progress bar's empty part is blank rather than a run
  of ━, which would show no progress at all.
- Everything lives inside one exported factory, screenKit(). A new
  top-level name that matches a local in a builtin policy or an audit
  detector (cmd, row, head, …) makes the bundler rename that local and
  rescans every user's audit history. The bundle gains one name, and
  engineVersion 10c367f637740d67 / detectorVersion 895c2fedece85e93 are
  unchanged.

* feat(config): remember which agents failproofai traces

Step 6a of the TUI redesign handoff. config.json gains an optional
`agents: { selected, seen }`: the remembered answer to the new setup's
"which agents should failproofai trace?". `selected` will drive hooks,
collection and backfill. `seen` is what was detected at the last setup,
so a newly installed agent can be offered as new.

- Absent means today's behaviour, every agent. writeConfig emits nothing
  in that case, so an untouched config file keeps its exact bytes, and a
  garbage value reads as absent. A broken file can never quietly switch
  tracing off.
- updateConfig carries the selection forward unless a patch replaces it
  (`null` clears it). Every mutation path goes through it, the wizard's
  own connect step among them, and an unrelated write that dropped the
  key would silently turn every unticked agent back on.
- The ids are kept as written, minus non-strings and repeats. The daemon
  owns the list of real agents and warns about an unknown one.
- isAgentTraced(id) is the one predicate per-agent surfaces ask.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93).

* feat(policies): one shared answer to "is this machine enforcing?"

Groundwork for the redesigned policies and launch screens, which both
warn when policies are not enforcing. One predicate,
notEnforcingReason(), answers for both so they can never disagree. It
returns "no-hooks" (no agent calls failproofai), "no-policies" (wired,
nothing on, counting policies switched off after install) or
"observe-only", and null when something enforces.

It reads files only, so it is cheap enough for the launch path:
installed packs, cloud-managed policies, configured custom policy files,
convention files on disk (counted, never imported) and the legacy
migration shim.

Deliberately not causes, because each needs its own words:
- A refused pack and a down daemon on a daemon-configured machine fail
  closed and DENY calls. "Not enforcing" would say the opposite of what
  is happening.
- A session pause belongs to one session, not to the machine.

Audit cache keys unchanged.

* feat(tui): pickers in the 2026-10 screen language

Step 5 of the TUI redesign handoff. selectOne and multiSelect are drawn
like the design's policiesAdd and agentPicker screens:
- an UPPERCASE heading with grey meta, by default a live "n of m
  selected" that updates on every toggle
- a pink `›` cursor gutter and a bold cursor row; ■ ticked and □ not
- bold section names, `↑/↓ n more` when the list scrolls
- the key hints at the foot
Answered prompts collapse to the heading and `✓ <answer>`, or to the
caller's own lines. The spine glyphs are gone from both.

- The window is 12 rows, not 8. Twelve reproduces every frame in the
  design; eight cannot show the nine agents of its setup screen. The
  WINDOW identifier itself is kept, because the audit cache keys on
  bundled names.
- Plain `a` toggles all, as the design's hints say. ctrl+a stays as an
  alias.
- New opt-in options for the config flow's agents step: a meta
  function, per-row tags recomputed on every toggle, the ✕ message for
  minSelected, and a caller-defined collapsed form. selectOne gains
  `meta`, `collapsed` and `spaceSelects`.
- The `policies -i` / `--install` / `--uninstall` agent menu was
  install-prompt.ts's own raw-mode loop, with a two-write (non-atomic)
  repaint and a colour gate that ignored pipes. It now uses selectOne.
  It keeps its "not installed" section of forward installs, Space still
  picks and Esc still exits 130. Its aggregate row reads "All N agents",
  as the design spells it.
- The two raw yellow warnings in the same file are now ▲ lines through
  the palette, so they respect NO_COLOR and pipes, and they drop a
  stale six-agent list.
- install-prompt.ts's searchable policy picker had no caller since
  `--install` stopped choosing policies. It is deleted with its tests
  (approved), and the manager tests keep their real assertion: setup
  writes the policy selection back unchanged.
- The pack pickers in pack-cli.ts take the heading meta instead of
  hand-written hint strings. The pack picker is preceded by the design's
  `failproof ai  v…  ·  Add <pack>` header, the per-pack agent picker
  shows display names, and Jev checks are still stated before the list.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93). Full unit suite green.

* fix(otel): preserve agent settings and require real Copilot installation

* feat(otel): add optional durable TypeScript and Python span exporters

* fix(otel): initialize delivery before collector startup milestone

* feat(help): every help page in the 2026-10 screen language

Step 3a of the TUI redesign handoff. Every `<command> --help` is now the
design's helpPage: the wordmark header, then USAGE, OPTIONS and
EXAMPLES, and nothing else (decision D11). Explanations and the privacy
footer are gone (D6), and docs/ is not touched.

Each page was rebuilt against its command's real argument parsing, not
its old text. Every real flag is kept with its real default, and the
reference's misses are corrected:
- config: adds --session, --connect and --machine-id. --machine-label
  says it renames and never runs setup, which is what it does today.
- policies: adds the --install/--uninstall lanes, -i/-u, --only, the
  12 --cli ids and `all` for --scope when removing.
- jev: one usage row per subcommand. The design's `status | test |
  models | remove` runs as a shell pipeline if pasted.
- publish: adds --min-cli-version and --entry.
- flush --wait says what it actually checks: the queue is empty.
- update --no-daemon: "migrate everything", not "settings only".
- uninstall --yes: also removes the daemon, and is needed without a TTY.
- backfill --since documents 1y and YYYY-MM-DD, which it accepts.
- Examples never put a key on argv: config shows `read -rs` into
  FAILPROOFAI_CLOUD_TOKEN, the form the CLI's own warning recommends.

The index follows the design: GET STARTED (three numbered steps that end
in a command), then SET UP, ENFORCE, OBSERVE and LESS OFTEN, one row per
command, then EXAMPLES. It fits 80 columns with light rewording (taller
is allowed, D10). `config --status` takes the --token example's slot,
and the last line names `failproofai -v` and the docs.

- `policies add|remove|show --help` prints the one policies page, which
  carries every flag of every lane, so `help policies add` lands right.
- Prose is shortened with `…` only on a terminal; commands are never
  cut (D9), and piped output never is.
- The hook page is titled `hook`, the topic you type.
- The usage `harness` and `jev` print after a mistake now uses the same
  rows as their help pages.
- Fix: `policies remove` without a terminal suggested
  `remove <owner>/<repo> --policy …`. Pack-lane remove takes an id and
  no flags, so following that hint uninstalled the whole pack.

Tests: help-index.test.ts has a new contract for the taller index:
- at most 36 lines and 80 columns, `·` the only wide character, no
  escapes in a pipe
- six headings in order, and three numbered steps ending in a command
- the indexed commands must EQUAL bin's SUBCOMMANDS
- `help X` byte-identical to `X --help` for every one of them
- no module-scope assertions, so a layout change fails tests, not
  collection
The bare-publish guard is re-anchored on markers the new page really
prints, so it cannot pass vacuously. The audit help test drops the
local-only promise D6 removed and checks the option column instead of
wrapped lines.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93). Full unit suite green.

* test(otel): isolate optional-library and initial-render assumptions

* feat(daemon): agent selection gates collection; scoped backfill requests

Step 6b of the config v2 plan: the Rust half of "which agents does
failproofai trace". The CLI writes the selection (6a) and the backfill
requests; the daemon obeys both.

Contract
- config.json may carry a top-level `agents: {selected: [...], seen: [...]}`.
  fpai-collect's config::load parses `selected` into a new
  CollectorConfig::agents (AgentSelection), so the existing 5-second
  PartialEq compare cycles the collector when it changes, with no restart.
  `seen` is not read, so a newly detected agent cycles nothing. No `agents`
  or no `selected` means every harness, as before. A value that cannot be
  read counts as absent, the way readAgents in fp-config.ts reads it, and
  gets a journal line. No deny_unknown_fields, so older daemons keep
  ignoring the key.
- One table, agents::SOURCES, maps every source (= top-level cursor dir)
  to its agent: claude <- claude, claude-subagent; openclaw <- openclaw,
  openclaw-sqlite; the other ten 1:1; `hooks` belongs to no agent. The
  collector gate and the backfill rewind both resolve through it. Nested
  stores inherit their top-level dir's agent; unknown top-level dirs are
  skipped by scoped rewinds. HARNESS_KEYS is untouched.
- An unselected harness starts no task: not its default roots, not its
  extra paths, not any Hermes profile. Hook activity and SDK spool delivery
  always run. Journal: "agents.selected names <x>, which has no collector
  source; ..." once per build, outside the sessions block; "<h> is not
  traced; its N extra capture paths are idle"; and one line naming what a
  selection collects.
- state/backfill-request.json is {sinceMs, requestedAtMs, agents?, kind?}.
  No kind = "user"; no agents = every selected agent (every agent when
  there is no selection). "user" forgets the cursors of files modified at
  or after sinceMs in the named agents' top-level AND extra-path stores,
  plus `hooks`; Hermes profile stores are left alone. "added" forgets every
  cursor of the named agents, Hermes profiles included, never touches
  `hooks`, and sets their first-sight window to the default 7 days. Named
  agents are taken as named, not intersected with the selection: config
  writes the request before the selection that ticks them.

Deviations from today
- A user backfill now rewinds nested extra-path stores (<source>/<label>/,
  Hermes extras included). The old rewind read top-level stores only.
- The process-global, sticky BACKFILL_SINCE_DAYS is replaced by a
  per-agent window (backfill::Windows), captured when tasks are built and
  kept until a later request for that agent. A scoped backfill no longer
  widens other agents' windows, and an agent added after a backfill starts
  on 7 days. A sinceMs in the future gives a 1-day window instead of 0.
- "added" requests older than 10 minutes, or without requestedAtMs, are
  dropped with a journal line; "user" requests are honoured at any age.
  An unknown kind or a non-list `agents` is discarded, not guessed at.
- A pending request is applied before the collector first starts and
  before it restarts after collection is re-enabled, as well as on each
  tick (still ahead of the config compare).
- filetail's "file source started" line now carries since_days.

Tests
- fpai-collect: AgentSelection parsing (absent, seen-only, garbage, the
  CLI's normalisation); a selection change alters CollectorConfig and a
  seen-only change does not; CursorStore::forget_all and
  forget_modified_since.
- failproofaid unit: the exact task list with no agents key (regression);
  an unselected agent's tasks, extra paths and Hermes profiles absent; the
  table covers every registered task and equals HARNESS_KEYS; journal
  text; request parsing and scope; per-agent windows (a scoped backfill
  does not widen another agent's; an agent added after a 30-day backfill
  gets 7); user rewind (claude reaches claude-subagent, openclaw reaches
  openclaw-sqlite, nested extra-path stores rewound, Hermes profiles not,
  hooks rewound); added rewind (everything incl. profiles, hooks
  untouched); a request with no agents and no kind covers the selected
  agents; a stale added request is dropped, a stale user one honoured.
- agent_selection_e2e, against the real binary with an isolated HOME: no
  agents key starts all 18 tasks; an unselected agent and its extra path
  never start; an unknown name is warned about on every build with
  sessions off; a mid-run selection change cycles to the right sources and
  a seen-only edit does not; a pending request is applied before the first
  start; a stale added request is dropped; a fresh one forgets everything
  but hooks; a re-added agent never resumes stale cursors even when the
  request is read before the selection lands.

* feat(tui): the cloud-key prompt, and a helper to collapse sudo's prompt

Step 6c of the TUI redesign handoff: the CONNECT TO CLOUD field that
config v2 draws, as its own prompt (promptCloudKey) rather than options on
promptText. promptText keeps its one-row rule, which its other callers
depend on.

What it does:
- One masked "API key ›" field, one • per character, under an UPPERCASE
  heading with the destination host as grey meta.
- Tab resolves OPEN_SOURCE, a sentinel like BACK.
- A saved key is drawn at once and checked in the background (D5).
  - works: ✓ line
  - refused: ▲ line, and the step behaves as if no key were saved
  - unreachable: ▲ line, and Enter still keeps it, unverified
  Enter waits for a check still in flight.
- A typed key is checked by the caller's function, with a spinner after
  150 ms (80 ms frames). A refusal prints one ✕ line, keeps the key in the
  field and switches the hints to "enter try again".
- Under 8 characters: an inline ✕, no server call. An empty Enter with no
  saved key: the caller's ✕ line.

Spec fix 11:
- A tab or newline inside a paste no longer switches to open source or
  submits halfway. Bracketed paste is turned on, and Node and Bun both
  report its start and end as keypresses.
- Keys are ignored while a typed key is being checked; Esc still cancels.
- Cursor and paste mode are restored on every exit, including a process
  exit while the prompt is open.

Copy change, flagged for review: the saved-key hint reads "type to replace
it" instead of the reference's "type a new key to replace it". The
reference is drawn at 104 columns; at 80 the longer line is cut through
the Tab hint, the only place Tab is mentioned.

collapseAfter(stdout, rows, fn) reserves rows, saves the cursor, runs fn
and erases back, so the wizard's DAEMON step can collapse sudo's own
password prompt to one line. The rows are reserved first so the prompt
never scrolls the saved position away.

Tests: 22. They cover every key path, the paste guard, the four saved-key
states, Enter waiting for the saved check, the spinner delay, terminal
restore, the key never reaching the output, colour on and off, and
collapseAfter with and without a terminal.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93).

* feat(config): the v2 setup flow — key, agents, apply

Step 6d of the TUI redesign handoff. `failproofai config` is now
sudo → API key (Tab for open source) → which agents to trace → apply,
drawn in the 2026-10 screen language. The logomark and header open the
run, each step collapses to its heading and a ✓ line, and DONE says what
was written and whether anything enforces. The "Ready to apply?" step is
gone: Enter on the agents step is the last decision, and Esc anywhere
before it changes nothing (D3).

DAEMON
- Same logic: probe, version skew, broken worker, stale unit.
- sudo's own password prompt is erased after it answers, so the step
  collapses to "✓ Root granted to install failproofaid".
- The broken case keeps "every tool call is denied" on screen.
- The install still happens at apply, before anything else is written.

CONNECT TO CLOUD
- One field (promptCloudKey) with the host as heading meta.
- A saved key from either credential is shown at once and checked in the
  background (D5). Reading only the policy credential, as setup did,
  showed a collector-only machine as unconnected.
- Enter on an empty field keeps the saved connection exactly as it is:
  no connectToCloud, so collector settings are not reset. It writes
  mode: cloud if that is missing.
- Tab never disconnects (D4). On a connected machine the hint is
  "tab skip" and the line "Not connecting. The connection to <host> is
  unchanged."; --disconnect stays the way to disconnect.
- Keys are checked with introspect first (organisation and permissions,
  no side effect), with the old empty ingest probe on servers too old
  for introspect. A key with one of events:add / policies:pull is
  accepted with a ▲ note; one with neither is refused.
- A refused --token re-prompts on a terminal and still fails a headless
  run (spec fix 9). A --url or FAILPROOFAI_CLOUD_URL override is named
  in the heading.

AGENTS (D1: all twelve listed)
- A first run ticks what is detected. A re-run opens on the saved
  selection, with agents detected since then ticked and tagged "new on
  this machine". An upgraded machine opens on what is hooked at user
  scope today (spec fix 8: a gateway counts when any profile is hooked).
- Rows are tagged live: will be added / will be removed / not installed.
- seen accumulates across runs, so a PATH glitch cannot make an agent
  someone unticked arrive ticked as "new" later.
- No history wording (D7). A removal promises only what the code does.

Apply order (spec fix 3):
1. daemon
2. hooks for the chosen agents, and user-scope removal for the rest
3. the "added" backfill request
4. agents in config.json
5. the connection

Unticking takes failproofai's entries out of that agent's user settings
only. It never calls removeHooks, which also resets policy config in
every scope (spec fix 1). A test pins every policies-config.json
byte-identical.

backfill-request.ts is the one writer of backfill-request.json (spec
fix 6). Requests merge, keeping the stronger: a pending user request
stays user, the widest window wins, and no list means every agent. The
file is written atomically. An "added" request carries no sinceMs, so a
daemon older than `kind` drops it instead of rewinding every agent.
Config writes one only for agents added back, and only when something
will collect afterwards. `failproofai backfill` writes through it too.

The log-only check (spec fix 2): a hook event from an untraced agent is
evaluated as usual and logged once per session, through a bounded
record on disk. Allowing such a call would open a hole in a hook
somebody installed on purpose.

DONE:
- the generic "Policies are not enforcing yet." (D18) plus "Turn on
  ours", or "N policies are enforcing on M agents". The count comes
  from enforcingPolicyCount, which counts the way notEnforcingReason
  decides (spec fix 10).
- the custom policy files that will not load, moved here from the
  removed review step
- Jev's status lines, unchanged

Telemetry adds mode, agents_selected, agents_detected and
agents_changed. Counts only, never names.

Copy that differs from the reference, both for 80 columns:
- the agents meta reads "from last setup" / "as protected today"
- the DAEMON and CONNECT lines say what was checked, not what was
  written (D3)

Also: notEnforcingReason no longer calls a machine whose only pack was
refused "not enforcing". That pack fails closed and denies.

Removed with the review step: reviewLines, buildCompletionSummary,
policyNamesLine and their tests. Two review-screen tests are gone from
custom-policy-discovery.

Tests:
- configure-wizard: 85, rewritten for the new flow (decisions above,
  both Esc-before-apply cases, the byte-identical policy configs)
- backfill-request: 14 (read as the daemon reads, merge rules, atomic
  write, the backfill command)
- untraced-note: 5
- not-enforcing: 11 (refused pack, the count)

Full unit suite and e2e green. Audit cache keys unchanged
(engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93).

* feat(policies): the policies screens in the new language

Step 3b of the TUI redesign: `failproofai policies`, the result of
`policies add <pack>` and `policies show <pack>`, built from screenKit()
and drawn as designed (decision D14).

- The listing names each pack once as a `pack` row, counts `enabled N of
  M`, and lists policies ● on or ○ off under UPPERCASE category headings
  with "n of m" meta. Off policies fold into one "○ k more off" line per
  category, and the screen closes on "See all M with failproofai policies
  --all".
- New bare `failproofai policies --all` lists every policy, nothing
  folded. Wired into the listing parser only; its help row lands with the
  help rewrite.
- At most one attention line, most severe first: a pack that will not
  load and fails closed (✕, it denies what it covers; fix `failproofai
  policies remove <id>`), then notEnforcingReason()'s one generic
  "Policies are not enforcing yet." (D18; fix `failproofai config`), then
  a custom policy file that is not running, hooks in more than one scope,
  and an unknown policyParams key. The policy_params_validation_warning
  event still fires for every unknown key, whichever line is shown.
- Custom, convention and cloud-managed policies get sections in the same
  style. The scopes subtitle, the `Config:` footer and the explanatory
  paragraphs are gone (D6).
- The listing is a pure renderPoliciesScreen() over a gathered value, so
  it is tested with colour on and off at any width. listHooks keeps every
  other behaviour, including mirroring convention policies into the user
  config. Descriptions are shortened only on a terminal; names, ids,
  paths and commands never.
- `policies add` opens with "✓ Turned on N policies from <id>@<version>:
  <reason>.", then per-category counts and one line per outcome fact
  (rename, pin, Jev checks, min-CLI note, warnings), and closes on "Turn
  on the other K with failproofai policies add <source> --all". It no
  longer says "Turned on" when nothing was: a carried re-add says "Kept",
  a merge says how many are on, and a pack with nothing on, or with only
  Jev checks, says it was installed. The five reasons stay distinct.
- `policies show` opens with kv rows: publisher (from the source owner,
  never the self-declared id), commit when there is one, "N in M
  categories ■ on by default", and effect / requires / jev checks rows
  when they apply; then ■/□ rows per category and "Install with …". No
  release date (D16).
- screenKit() gains failed (✕), selected (■), unselected (□) and meta (a
  grey qualifier) as properties; tui.ts gains no top-level name.

Judgment calls where the design is silent:
- A pack row carries grey tags rather than new states: `observe`; its
  Jev checks; `from <source>` when the source is not the repository its
  id names (keeps the old anti-spoofing display); and `failed to load:
  <reason>` for a refused pack, so the reason and its fix stay visible.
- Category headings are prefixed with the pack id only when two packs
  have regex policies, so the shipped FailproofAI/policies +
  FailproofAI/jev-policies pairing reads as designed.
- Every off policy folds, as briefed (the reference also draws a few off
  rows by name), and the "See all" line shows only when something folded.
- An empty machine shows the generic line, "Turn on ours: …", and one
  more line in the same shape, "Or anyone's: failproofai policies add
  <owner>/<repo>", to keep first-run-hint's "ours is not the only
  channel" intent.
- A cloud-managed observe policy keeps ● and is tagged `observe`, like an
  observe pack. A convention file that fails to load is a ✕ row but no
  attention line, matching the old warning set.
- The name column is derived from the names shown: at least the design's
  32, two spaces past the longest, shared by every pack section.
- `add`'s closing command uses the source as typed, never the id, which
  can name a repository the pack did not come from. "Pinned to" names
  the full source, owner/repo@tag.
- The Jev checks section on `show` keeps its rows and notes; only its
  heading moved to the new style.

Tests that pinned the old screens are updated deliberately, keeping each
intent, with negative chip pins rewritten as positive checks in the new
vocabulary; manager-cloud-listing now runs against a temp home. New tests
cover --all, folding, multi-pack prefixes, the observe note, precedence,
the show screen, colour on and off, and the new kit pieces.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93).

* feat(config): status and pause in the new language

Step 3c of the TUI redesign: `failproofai config --status` and the
`--pause` / `--resume` confirmations, built from screenKit().

config --status (new src/hooks/config-status.ts)
- THIS MACHINE / CLOUD / ENFORCEMENT. Every kv row of all three sections
  goes through one kv() call, so they share one value column derived from
  the widest label (INV-24). Values are never cut.
- gatherStatusFacts() is the only part that reads the machine;
  renderStatusScreen() is pure, so every row and the order between
  problems are pinned without a daemon, a terminal or a clock.
- New data, approved in D16. The dashboard row probes the bind host from
  lib/dashboard-host.ts on port 8020 with a 150 ms connect. Delivery reads
  collector-health.json (nothing on this side read it before), the refused
  batches in state/failed and the spool backlog. Today's blocked and warned
  counts come from the hook-activity store's own windowed reader
  (getHookActivityEntriesSince); no second parser.
- At most one attention line, by severity: a daemon-configured machine
  whose daemon is not answering (every tool call denied), a pack that will
  not load (the calls it covers denied), the cloud refusing the key, not
  enforcing (notEnforcingReason), unhealthy Hermes profiles, the daemon
  down on a connected machine (macOS "unknown" gets its check command),
  other refused batches, then daemon/CLI version skew.
- Today's facts stay on screen as rows, so a worse problem cannot hide
  them: the skew on the daemon row, refused batches on the delivery row,
  one hermes row per unhealthy profile, macOS's unreadable service state
  on the daemon row, one row per paused session with its id and time left,
  and "Cloud-managed policies keep enforcing." whenever something is
  paused.

--pause / --resume (session-pause-cli.ts)
- "✓ Paused enforcement for this session until 14:32 (30 minutes).", with
  the duration actually granted (the 8h ceiling counts from
  firstPausedAt), an "Extended the pause ..." variant, then the cloud line
  and "Resume early:  failproofai config --resume". Resume answers in the
  same shape; errors are one ✕ sentence and one fix line.
- Clock, locale and time zone are injectable, so the tests are
  deterministic.

Also: kv() paints no empty colour span for an empty (continuation)
label, and flush-cli's spoolBacklog() adds the oldest queued batch's age
(pendingBatches counts through it).

Judgment calls
- The account row is "<org> on <host>". The host was today's "cloud
  connected to <url>" row; the design shows the org alone. With no org on
  record it reads "Connected to <host>". ", for reporting only" and
  "Not sending, connected for policy only" keep today's half-connection
  facts.
- The machine row keeps describeMachine's label plus 8-character id,
  because labels collide; the design shows the label alone.
- Dropped as fine print (D6): the layout version, the masked token row,
  healthy Hermes rows (with the copy-mode note), and the daemon
  paragraphs ("install it as a service ...").
- Two attention candidates the brief did not list. "Policies are not
  enforcing yet." is D18's shared predicate. A pack that will not load is
  listed above it, because such a pack fails closed and DENIES; calling
  that machine "not enforcing" would say the opposite of what happens.
- Delivery problems other than refusals stay on the row: no report yet, a
  record older than 2 minutes (the daemon rewrites it every 30 s), no
  delivery block (the daemon has no key), and a stuck backlog (the oldest
  queued batch and the last delivery both older than 15 minutes).
- Times are to the minute in the machine's locale (seconds dropped), and
  durations are in words. A renewal the ceiling cut short adds "Capped at
  8 hours from when this pause began."
- An explicit --session is named back, and its resume command carries it.
  The status screen closes with --resume --session and the id, or <id>
  when several sessions are paused.
- runPauseCommand's "status" action is gone. Its two tests moved to
  config-status.test.ts with the same intent. connectionStatusReport,
  connectionStatusLines and versionStatusLines are no longer called by
  --status. They stay for now (tested, and another step may reuse them)
  and can go in the clean-up step.

Audit cache keys unchanged: engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93.

* feat(launch): the dashboard launch screen in the new language

Step 4 of the TUI redesign handoff. Bare `failproofai` prints the
redesign's launch screen once the local dashboard is listening: the
logomark (terminal only), the header, a DASHBOARD block (url, policies,
agents, cloud), at most one attention line, LINKS and the key hint.

- scripts/launch-screen.ts: a pure builder over screenKit(), facts in and
  lines out, plus the ✕ block for a start that fails.
- scripts/launch-facts.ts: reads the facts from files only. policies come
  from the same inputs as notEnforcingReason(). agents come from
  agents.selected, else the agents hooked at user scope, by display name,
  with detectInstalled() kept off the path. cloud follows config
  --status's precedence (environment, credentials, org, refused uploads
  over the credential file's claim), without its systemctl read.
- scripts/launch-ready.ts: readiness (decision D16). Next's "Ready in"
  line; a TCP probe of the bind address every 250ms after 5s without it;
  a 30s deadline that prints the screen with "○ starting"; and an exit
  before ready reported as one ✕ line with the real cause. For a taken
  port it drops Next's stack trace and says the port is in use. Server
  output is held until the start is decided, so the screen opens it.
- scripts/skew-log-filter.ts: makeNextStartupFilter() drops Next's four
  startup lines until the ready line, beside the skew filter, whose
  pass-through test is untouched.
- scripts/launch.ts: launch(mode, { screen, logo }). Audit's hand-off
  passes screen: false and keeps its own lines (LAUNCH-1); the bin passes
  logo: false after the first-run wizard ran (LAUNCH-8). FORCE_COLOR
  reaches the child only when stdout is a terminal (LAUNCH-M2). The
  exposed-bind warning is the screen's attention line and outranks "not
  enforcing"; with no screen it goes to stderr before the server starts.
  Dev mode keeps inherited stdio and prints its screen up front, marked
  starting.
- The audit tests that pinned launch("start") now expect the screen
  option, the value this change sets.

Judgment calls:
- docs is https://docs.befailproof.ai. docs/index.mdx is the deployed
  docs' home page; the /introduction page #461 pointed at moved to
  docs-old/ in #699 and has no redirect in docs.json.
- The shipped CLI cannot change its port (the bin rejects --port and
  launch sets PORT itself), so the port-in-use line says how to free it.
  Only the contributor scripts, which take --port, are told to pass
  another one.
- policies reads "N on from <id>@<version>", "N on from K packs" or
  "K sources", or "none on"; custom policy files are noted, never
  imported. agents names four and "and K more" past five. cloud gives
  half connections and refused uploads their own wording, so the row
  never says connected while nothing arrives.
- The security warning keeps all three consequences in one sentence, so
  its fix sits on the next line rather than inline.
- "The wizard drew the logomark" is read as maybeFirstRunConfigure()
  returning true. That also covers its two early refusals (sudo, an
  unsupported platform), which draw no art.
- renderLaunchBanner has no callers now. It is left for the step-8
  cleanup so this commit leaves tui.ts alone; renderBrandLogo and intro()
  are unchanged.
- The node:path and node:net imports are aliased, so the scope-hoisted
  CLI bundle renumbers and renames nothing outside the launch modules.

Audit cache keys unchanged (engineVersion 10c367f637740d67,
detectorVersion 895c2fedece85e93).

* feat(audit): per-agent progress and the audit screens in the new language

`failproofai audit` now draws the 2026-10 progress screen: a bar per
agent with sessions to scan and running counts of what was found,
finished in place when the scan ends (decision D16). The sign-in,
--schedule, --no-schedule and --status screens move to the same
language.

runAudit
- RunAuditOptions.onProgress: ONE `discovered` event with every agent's
  transcript total (0 included) and the number of policies a replay can
  fire, then one `transcript` event per transcript - cache hit, scan,
  failed scan or skipped task alike - with its agent and hit counts.
  Optional: the dashboard and the scheduled run do not pass it, and the
  result is identical either way. A callback that throws is caught, so it
  can never count as a scan error.

The audit screen (src/audit/cli.ts)
- buildAuditScreen() is pure: header("Audit"), SCANNING HISTORY with
  "<n> agents, <m> policies", one row per agent (bar(30) plus "301 of 412
  sessions" or "✓ 1,284 sessions"), FOUND SO FAR with "would block" /
  "would warn" / "audit only", then the key hint. Finished, the same
  screen reads SCANNED HISTORY / FOUND, every bar done, and the summary
  replaces the key hint. While transcripts are still being listed the
  heading meta is the dashboard's first stage label.
- Live only on a TTY with colour, through the kit's new live() region:
  at most one frame per 100 ms (tokens.json), each ONE write in
  synchronized output. The old spinner wrote its cursor-up and its lines
  as separate chunks and advanced on a timer. Piped or under NO_COLOR, the
  finished screen prints once, with no escapes and no frames. Prose is
  fitted to a terminal, never to a pipe (D9); the bar gives up width
  before the counts do.
- FORCE_COLOR is no longer read, matching tui.ts (OPS-24).
- The hand-off is one ok line naming the audit URL, then the key hint:
  "✓ The audit is ready:  http://localhost:8020/audit" and "ctrl+c stop
  the dashboard". Audit prints no logomark itself; the launch("start")
  call is unchanged (its banner is the other branch's change).
- AUDIT_STAGES stays and still mirrors the dashboard's RunProgress; its
  comment now says the CLI draws real bars (D16) and uses the first label
  for the discovery phase. The drift test passes unchanged; only its
  header comment is updated.
- audit --help is untouched.

Kit (src/hooks/tui.ts), no new top-level names
- screenKit().live(out): a redraw-in-place region. Cursor hidden from the
  first frame and shown again on done, on exit and on SIGINT/SIGTERM;
  a signal is re-raised once the cursor is back, so Ctrl+C still stops
  the command exactly as before. The last frame is never truncated.
- repaint() takes an optional frame { lead, tail, cut } so a cursor mode
  rides in the same write as the frame.
- promptText({ field: true }): the redesign's field, "  email     ›
  value", with a refusal as a ✕ line under it, drawn in one write and
  cleared as soon as the value changes.

Sign-in and scheduling
- cli-login: header("Audit"), SIGN IN, fields, one ✓/✕ line per outcome;
  no intro() logomark and no spine (LAUNCH-10). Validator messages are
  sentences now.
- schedule-cli --status: header("Audit"), SCHEDULE kv rows on one column
  (● / ○ states), then one ▲ per distinct problem with its fix (daemon
  first, then digests). --schedule and --no-schedule state results as
  sentences; the daemon warning is a ▲ line on stderr.

Judgment calls
- KEPT, despite D6, the one line `audit --schedule` prints about what
  each report sends: "Each report sends finding counts, redacted example
  commands and this machine's name." as one ink3 line under the result.
  `reportsConsentedAt` records that a person READ that disclosure before
  anything leaves the machine, so it is part of the consent, not fine
  print. A test now pins it. The product owner will be told explicitly.
- "would block" / "would warn" go by what the policy does, not by
  severityForBuiltin, the score's heuristic, which files protect-env-vars
  and prefer-package-manager under warn although both deny: warn-* would
  warn; sanitize-* would warn too, because it acts on PostToolUse after
  the call ran (#669); every other builtin would block. The audit-only
  detectors get a third row, "audit only", instead of being dropped or
  misfiled (OPS-11). All three rows always show, so the block never jumps.
- "<m> policies" counts the builtins a replay can fire (34 today): the
  Stop-only require-*-before-stop gates are registered but never asked.
- The summary says "already covered by your policies", not "already
  blocked": an enabled warn-* policy warns rather than blocks.
- The empty-history ending no longer points at `policies --install`: the
  audit reads each agent's own history, hooks or not. It says "Run it
  again after using your agent:  failproofai audit".
- Counts carry thousands separators (1,284 sessions), like the summary.
- The onboarding audit (runPostSetupAudit) shares the screen; its own
  lines are restyled: no ✦ banner, "▲ None of this is enforced yet.  ·
  failproofai policies add FailproofAI/policies", "✕ The audit could not
  finish.  ·  failproofai audit".
- --no-schedule drops "your session is untouched" (D6); the session is
  still kept, as before.
- The sign-in fields have no key-hint line under them: promptText draws
  one row, and the placeholder hint stays on that row.

Tests: audit-progress.test.ts (the callback on real fixture transcripts
for claude and factory, cache hits included), audit-screen.test.ts (the
builder at fixed widths, plain and coloured, mid-scan and finished; the
piped, NO_COLOR and TTY wiring, with every frame one atomic write),
live() and the field prompt in the kit tests, and the sign-in and status
screens. Assertions on the old buildSummary copy, the old status labels
and a lowercase "running" are updated deliberately; nothing is deleted.

Audit cache keys unchanged: engineVersion=10c367f637740d67
detectorVersion=895c2fedece85e93.

* feat(backfill,config): traced agents only, --agents, --oss

Step 6e of the TUI redesign handoff.

backfill
- Re-sends only the agents failproofai traces (agents.selected, or every
  agent when there is none).
- `--agents a,b` narrows it to a subset:
  - a name that is not an agent is refused ("Not an agent")
  - an untraced agent is refused with the reference's backfillUntraced
    copy
  - so is any daemon/CLI version mismatch, because an older daemon reads
    every request as "rewind everything"
  Nothing is written in any of those cases.
- Without --agents the request names nobody, so the daemon applies the
  selection current when it acts, not a stale copy.
- Session counts come from the audit adapters, the same readers the
  audit uses. They know all twelve agents and count sessions; the old
  file survey knew seven and walked the wrong directory for three
  (OPS-1).
- Database-backed agents (Goose, OpenCode, Devin) say "all N sessions,
  whatever the window": the collector reads them by row and re-sends all
  of it. Hermes says it is not re-sent, because the daemon does not
  rewind its profile stores until they have a window (D7).
- The screens follow the reference: backfillDry (WOULD RE-SEND,
  traced only, "Not traced, skipped: …"), backfill (one ✓ line), and
  backfillUntraced.
- The result says when the data arrives ("over the next few minutes",
  watch with config --status) instead of implying flush --wait finishes
  it (OPS-3). It keeps the streams caveat and the stopped-daemon line
  (OPS-2).

config
- `--oss`: skip the connect step. A connection already present is left
  as it is (D4). It overrides FAILPROOFAI_CLOUD_TOKEN; only an explicit
  --token contradicts it, which is an error.
- `--agents a,b|all`: any of the twelve, validated before the wizard
  runs, and the agents question is skipped.
- Both are documented in config --help.
- The help index's config row now says what setup asks: "Connect to
  cloud or use open source, and pick agents to trace". This is the
  design's row, trimmed to fit 80 columns.

config --status counts agents found on this machine but not traced
("8 traced, 1 not traced, 3 not installed"), as config-flow.md asks.

harness list / add-path
- Drawn with the kit: a heading per agent with its count, rows of path
  and agent ids, one ✓ or ✕ line per result.
- An agent that is not traced gets a ▲ line, because the daemon starts no
  task for it, extra paths included.

Fixes (pre-existing, found while restyling):
- `backfill 6m` (no --since) silently ran the default 30 days. Every
  stray word is now refused, with "did you mean --since 6m" when it
  looks like a window.
- `config --token K --machine-label L` ran a rename and ignored the key
  (spec fix 9). With --token, --oss or --agents it is now the name to
  set up under.
- `config` ran full setup with an unknown option silently ignored. An
  unknown option or a stray word is an error now, and `config status`
  suggests `config --status`.
- backfill's not-connected error taught `config --token <key>`, a key
  on argv. It points at `failproofai config`.
- `harness add-path` claimed `harness list` shows what is captured. It
  reads the same file; the daemon's log is where rejections show up.

Tests:
- new: backfill-cli (11)
- updated to async: backfill-request
- harness-extra-paths: the ━━ rule pin becomes the UPPERCASE heading,
  plus untraced tests with their own temp home
- config-status: not-traced count
- cli-args e2e: --oss with --token, unknown agent, unknown option, the
  bare-word suggestion, help rows, `backfill 6m`

Full unit suite (8158) and e2e (341) green. ~/.failproofai/config.json
was checksummed unchanged across the run. Audit cache keys unchanged
(engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93).

* fix(daemon): a config edit right after a backfill was never applied

After applying a backfill request, the collector manager rebuilt the
collector from the config read at that moment, then read the config
again to record what was running. An edit that landed between those two
reads was recorded as already running, so the next compare saw no change
and the edit was never applied.

This was latent on main. Setup v2 makes it routine: when `failproofai
config` re-adds an agent it writes the `added` backfill request first
and the selection right after, which is exactly that window. The
re-added agent stayed untraced until some unrelated edit cycled the
collector.

The fix records the config read BEFORE the build as what is running,
the same rule the config-change branch below it already documents ("What
was BUILT FROM, not a fresh read"). An edit landing between that read
and the build now costs one extra cycle instead of being lost.

agent_selection_e2e's
an_agent_re_added_on_a_running_daemon_never_resumes_its_stale_cursors
reproduces it. It failed 2 of 3 runs on an idle machine before the fix
and passed 6 of 6 after. cargo fmt, clippy -D warnings, and the
failproofaid crate's 190 tests all pass.

* fix(config): --disconnect no longer sends you to restart the daemon

The disconnect result said a running failproofaid "keeps the key it
started with, so restart it to stop the current process sending". That
was true when the collector manager started once per daemon lifetime.
It now polls the collector config every interval, and that config
includes the ingest credential (CollectorConfig::ingest; is_enabled() is
ingest.is_some()), so the collector stops within one interval once the
key is gone. The restart advice sent people to do work that was not
needed, and it needs root.

The line now says the daemon stops sending within a few seconds. The
test that pinned the restart wording tested exactly this value, so it
is updated to pin the corrected one.

* fix(otel): lazy imports, isolated setup and precise local configuration

* fix(policies): show that the pre-pack shim still enforces builtins

On a machine with `enabledPolicies` and no regex pack installed,
handler.ts registers those builtins itself: the migration shim. The
policies screen showed none of it, so it drew only the convention files
while thirty builtins enforced. config --status counted them ("30 on
from legacy builtins"), so the two screens disagreed. Found in a QA pass
of the merged screens.

The screen now has a BUILT IN heading, "N on, from before packs",
followed by the command that moves them into a pack. It uses the same
hasInstalledRegexPacks() test the hook path does. It is a count, not a
list of names, because #738 pins that this screen names no builtin
(cli-args e2e: the list never contains "block-sudo"). A machine like
that is no longer drawn as empty either.

Also: config --help's --machine-label row said "never runs setup". Since
6e, `--token K --machine-label L` sets the machine up under that name,
so the row now says so.

* feat(cli): flush, migrate, update and uninstall results in the new look

printReport is the shared presenter for the action commands that return
bare facts: flush, migrate, update and uninstall. It now draws the
2026-10 shape instead of the retired `title()` + indented-wrap one:
- the kit header names the command, with any meta after it
  ("Migrate · dry run")
- top-level lines start at column 0
- lines a module indented on purpose keep their indent
- a failed run (exit non-zero, printed to stderr) opens with ✕, and what
  follows is indented under it as the fix

Backticked commands still turn pink and lose their backticks. Wrapping
still happens before colouring, so widths stay right. Paths are never
broken, because wrap() only breaks at spaces.

Restyling the presenter, rather than each command, is the review's plan
for screens the design does not draw: "restyle the shared pieces in
place so most follow automatically".

* feat(hooks): installs and removals keep the agent selection true

Step 6f of the TUI redesign handoff: decision D2, "turn the agent back
on". `agents.selected` in config.json drives which agents `failproofai
config` hooks and which harnesses the daemon collects, but only `config`
wrote it, so:

- `policies --install --cli goose`, on a machine whose selection left
  Goose out, wired Goose, and the next `failproofai config` silently
  removed those hooks again.
- `policies --uninstall --cli goose` left Goose traced, so its sessions
  kept being collected and the next `config` put the hooks back.

Now:

- Installing an agent's hooks, at any scope, adds it to
  agents.selected (INTEGRATION_TYPES order), and to `seen` when
  detectInstalledClis() finds it. With no `agents` key nothing changes:
  every agent is traced already.
- Removing an agent's hooks at user scope or `--scope all` takes it
  out. With no `agents` key it writes `selected` = every
  INTEGRATION_TYPES id but the removed ones, `seen` =
  detectInstalledClis(). An empty selection is allowed.
- One line after the command's own output, built with screenKit().ok:
  "✓ Goose is traced again, so failproofai config keeps its hooks." /
  "✓ Goose is no longer traced, so failproofai config won't add its
  hooks back." Several agents read "A, B and C are ...". A quiet
  install prints nothing.

Covered: `policies --install` / `-i` (with --cli or the agent picker),
`policies add <name>`, `policies --uninstall` / `-u`, `policies remove
all`, and the dashboard's per-agent panel (app/actions/
install-hooks-web.ts, server side). `policies add <owner>/<repo>` and
the bare `policies add` picker install packs, not hooks. Untouched:
`failproofai config` (its own installHooks call writes no selection and
prints no note), `failproofai uninstall`, and project- or local-scope
removals.

The logic is one function, keepAgentSelectionTrue, in the new
src/hooks/agent-selection.ts. It is that module's only top-level name,
so the bundle gains nothing a policy or detector local can collide
with; the audit cache fingerprint is unchanged
(engineVersion=10c367f637740d67 detectorVersion=895c2fedece85e93).

Judgment calls:

- Opt-in, not default. installHooks takes `syncAgentSelection` in its
  options and removeHooks in its opts; bin's four call sites and both
  dashboard actions pass it. Default-on would need the wizard and the
  full uninstall to opt out (or a check on the `source` telemetry tag),
  would make every bare removeHooks() write config.json (manager.test.ts
  pins exactly three settings-file writes for --scope all, and none
  when there is nothing to remove), and would let any caller that does
  not isolate FAILPROOFAI_HOME rewrite a real config.json.
- A removal untraces every agent the command acted on, including one
  with nothing to remove: "No settings file found. Nothing to remove."
  is followed by the note. The command still said "not this agent",
  and leaving it traced lets the next `failproofai config` hook it.
  This includes the Claude Code default when no agent CLI is on PATH.
- An install traces only agents whose hooks were actually written, so
  `policies add <third-party pack policy>`, which wires no hooks,
  traces nothing.
- Adding an agent rewrites `selected` in INTEGRATION_TYPES order. The
  wizard saves it detected-first, but every reader re-sorts, so only
  the file changes. Ids this build does not know (a newer build's
  agents) are kept, after the known ones. `seen` is rewritten only when
  an agent is added to it.
- Plural notes say "their hooks" where the singular says "its hooks",
  alongside the plural verbs. "failproofai config" in the note goes
  through kit.cmd: pink with colour on, the exact text with it off.
- A config.json write that fails does not fail the command, because the
  hooks are in place. It prints a ▲ line naming the reason and the
  consequence instead of the ✓ note.
- `policies remove <name>` passes the flag too. Its only effect is on
  `policies remove all`, which removes hooks; a policy name takes the
  policy-only path, which never touches the selection.
- A quiet install still writes the selection; only the note is muted.
  No current quiet caller opts in.
- The dashboard panel shows errors only, no success messages, so its
  UI is unchanged and the note lands in the server log.
- Left alone: `failproofai update`'s Hermes migration. It rewrites
  hooks a profile already has (shell hooks to the native plugin) and
  installs for no new agent.

Consequences worth knowing:

- A project-scope install now traces the agent machine-wide, so the
  next `failproofai config` also gives it user-scope hooks.
- On a machine with no config.json, a user-scope removal creates one
  (defaults plus the selection), as `harness add-path` and the
  telemetry opt-out already can; detectLayout reads that home as
  current. Its first `failproofai config` then opens on that list
  (every agent but the removed one, undetected ones included) rather
  than on the detected agents.

Tests: __tests__/hooks/agent-selection.test.ts (32),
__tests__/dashboard/install-hooks-web-agent-selection.test.ts (3),
__tests__/e2e/cli/agent-selection.e2e.test.ts (3, through the real
binary). tsc and eslint clean; full vitest 391 files / 8179 passed;
e2e 18 files / 337 passed. A first full run on a loaded machine had 17
timeouts in unrelated files, which all passed re-run alone.

* test(cli): pin the new failure shape of action results

A failed flush prints the kit header, then ✕ and the module's sentence,
then its fix indented two columns, with no literal backticks. Also moves
the printReport CHANGELOG line from Fixes to Features, where a new look
belongs.

* feat(policies): show --releases and the empty picker in the new look

Two pack-lane screens still used the retired title/stack/emptyState/
nextStep helpers:
- `policies show <owner>/<repo> --releases`: the kit header names the
  repository (the id is case-sensitive, so it is never uppercased), with
  a RELEASES heading and its count. The releases stay a column table,
  since the kit's rows are two-column. The "—" note is grey, and the
  install command is pink.
- `policies add` / `remove` with nothing installed: the same three
  doors the policies screen's own empty state offers (ours, anyone's,
  look first), as indented pink commands.

The facts the tests pin are unchanged: the release count, "no releases",
"— means the release did not record it." and "No policies are installed
yet."

* fix(config): an unattended re-run keeps a saved empty selection

Removing every agent's hooks (policies --uninstall, after 6f) saves an
empty selection. An unattended `failproofai config` then fell back to
all twelve agents, because the fallback for an empty start was meant
for a brand-new machine with nothing detected. It put back every hook
the user had just removed.

A saved answer now stands even when empty. The run hooks nothing,
because an empty list is not something to hand installHooks, and the
AGENTS line reads "▲ Tracing no agents · failproofai config". The
interactive picker still needs at least one agent.

* feat(config): --connect, --disconnect and rename results in the new look

They printed bare lines with no heading. They now go through printReport
like the other action results: the kit header names what ran (Connect,
Disconnect, Rename), and a failure opens with ✕ with its fix indented
under it. The modules' own lines are unchanged, so their tests are too.

* feat(notices): errors, notices and the one-off screens in the new language

Step 7b of the TUI redesign. Every error now prints as the design's
notice: `✕` and what went wrong, then the fix on its own line with the
command in pink. The remaining one-off screens move to the screen
language: the uninstall question (D17), the banners printed before a
command, the publish done screen and `jev status`.

Errors
- One printer for every CliError, bin's and the manager.ts ones it
  carries: `Error: <message>` becomes `✕ <line 1>`, every later line a
  fix line indented two, and a `backticked` command painted pink with the
  backticks dropped (new screenKit().notice). Colour is decided from
  stderr; exit codes are unchanged. Written with process.stderr.write and
  drained through exitAfterFlush, because Bun paints console.error red
  on a terminal, a colour from outside the palette. "Unexpected error"
  stays plain: that path cannot trust the install enough to load the kit.
- Q4, the design's words: "There is no command called 'status'." then
  "Did you mean  failproofai config --status?", from the new
  src/hooks/did-you-mean.ts. A table of words people type comes first
  (status, pause, resume, login, setup, install, logout, upgrade, hook,
  dashboard and more). Then the nearest subcommand by spelling: a swap of
  two letters counts as one edit, and a unique prefix wins, so `back` is
  backfill, not `pack`.
- Q6, a distance cut-off: two letters must match exactly, up to four
  letters one edit, longer words two. `install` suggests `policies
  --install` (the table is asked before spelling), `-i` no longer
  suggests audit, and a word close to nothing gets no guess and points
  at `failproofai help`. Flag suggestions are whole commands
  (`failproofai --version`), and --hook is never offered.
- Q5: `--cli` in policies --install, --uninstall, add and remove goes
  through one helper reading INTEGRATION_TYPES: "Not an agent: claud
  (did you mean claude?)" or "Missing agent name after --cli.", then all
  12 ids on two rows.
- Every other bin error reworded per the inventory: full stops, two
  spaces around commands, label-shaped first lines rewritten (policies
  show, --custom), `policy add` is `policies add`, the remove lane's
  missing --scope gains `all`, the add|remove lane's unknown flag points
  at `failproofai policies <add|remove> --help`, --version's extra
  argument now says `failproofai help` like --help's, and a missing
  --token value points at FAILPROOFAI_CLOUD_TOKEN, never argv. The
  unreachable "Unknown policies subcommand" branch is deleted (ERR-11).
- The --hook usage error is the notice shape written out by hand, with
  no colour (see the audit-cache note below).
- manager.ts: fix lines gain backticks so their commands paint. The two
  errors it printed itself (`Error: …` for a custom-policy file that
  will not load or registers nothing) go through the notice too.

Uninstall (D17)
- ONE question: `Continue? y/N`, from the new screenKit().confirm. It
  reads raw keys like the pickers; only y or yes, then Enter, is a yes,
  and Enter alone, Esc, Ctrl+C, Ctrl+D or closed input is a no. Above it,
  a plan read off what the steps do: "This removes failproofai's hooks
  from N agents and the failproofaid service (needs sudo).", the agents,
  "Also resets enabled policies, custom policy paths and policy
  parameters in policies-config.json." (removeHooks(…, "all") does that),
  and what is kept, or what ~/.failproofai holds with --purge.
- Yes is yes to the whole plan, the service included, which is what
  --yes always meant. The second "remove the service too?" question is
  gone; keeping the daemon while removing hooks is `failproofai policies
  --uninstall`. --yes still asks nothing and removes the service, and
  without a terminal and without --yes it still refuses with exit 1.
- The outcome is ✓ / ✕ lines under header("Uninstall"). removeHooks
  gains an opt-in `silent`, so its own `policies --uninstall` rows no
  longer land between the question and the outcome.

Banners before a command (one state line and one fix line each)
- fp-reset: a newer layout is a ✕, since it stops the command. The
  stale-home reorganisation is a ✓ with what was rebuilt and what was
  kept, a ▲ for undelivered batches and a ✕ for a failed step. The three
  daemon self-heals and the version-skew hint are ▲ plus a fix, with
  "denies every tool call" kept on one line. The kit is loaded with
  import() inside checkLayoutForCli, so handler.ts's hook path (it
  imports fp-reset) never loads the renderer.
- The `--token` warning, and the aborted-setup hint in
  onboarding-attempt.ts.

publish and jev status
- publish: header("Publish"), a ✓ per side effect (git commit, bundle,
  created repository), "✓ Published <id>@<version> with N policies."
  (the repository and tag are named only when they differ from the id
  and version), then "  Anyone can install it:  failproofai policies add
  <repo>". The private-repo variant puts a ▲ in the install line's place,
  and the private refusal is a ✕ notice.
- jev status: header("Jev"), a status row, then provider and model,
  endpoint, mode, timeout, key, config with its permissions, and what Jev
  may clear; any ▲; the ACTIVITY block; the next commands last. There is
  no "last test" row (D16: nothing records one) and no key tail (nothing
  in this module prints the key). --json is untouched.

Root-required copy
- update: an install that needs root says "✕ failproofaid <v> was not
  installed: root privileges are required. The previous daemon is
  untouched." and "Run `sudo -v` and then `failproofai update` again",
  instead of "re-run failproofai config" plus a unit file to paste.
  printReport paints a leading state glyph by role.
- uninstall: a service it could not remove gets "Remove it as root:",
  the exact sudo commands, and where to check it.

Audit cache keys unchanged: engineVersion=10c367f637740d67
detectorVersion=895c2fedece85e93. The first build moved engineVersion:
Bun orders modules by where each import() first appears, and a kit
import in the --hook usage-error branch, the first import in bin,
pulled tui.ts ahead of builtin-policies.ts. Its top-level `ctx` then
renamed every policy's `ctx` parameter to `ctx2`. That branch now
imports nothing, and the builtin-policies module is byte-identical to
the base build.

Tests updated on purpose (copy or behaviour this change sets)
- cli-args.e2e: "Unknown command: X" becomes "✕ There is no command
  called 'X'.". The "always offers a runnable suggestion" case becomes
  "suggests nothing for a word close to no command", because Q6 changes
  exactly that. The closest-flag case uses --versoin, since
  --unknownflag now gets no guess.
- help-index: the unknown-topic error's new words. The index check now
  looks for those words; checking the old ones would test nothing.
- uninstall-cli: the dry-run and cancel lines, the plan prefix, and the
  "who decides the daemon's fate" block rewritten for one question (the
  two keep-the-service cases now expect removal on yes, per D17).
- publish-command: "Published X to Y at tag Z." becomes "Published X
  with N policies." in seven places. The short form is printed only when
  tag and version agree, so the pin still guards that, and the comments
  say so. "3 assets attached" is dropped (the uploads are asserted
  directly), the install line has its new words, and "PRIVATE" is
  "private".
- jev-cli, jev-cli-status-reviewable, jev-cli-status-stats: "Jev is off"
  becomes the status row "○ off: there is no …". jev-cli-cloud: the
  separate-steps check reads one-line steps.
- fp-reset kept every pinned phrase; the test that wants what was
  rebuilt AND what was kept is why the rebuilt line stays.

New tests: did-you-mean (table hits, typos, the cut-off, silence); the
kit's notice() and confirm() with colour on and off; the D17 question's
exact wording and colour; the banners' shape and colour; the update
root-required lines; the publish done screen (side effects, a
differing tag, colour on a terminal); the jev status layout and colour;
the --token warning's shape; and the --cli errors and the printer's
shape end to end.

Judgment calls
…
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.

2 participants