From d1351a219a1eef6d92998a6ea48e8446d2087d68 Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 09:38:46 +0700 Subject: [PATCH 1/2] docs: remove monolithic specification Move durable rationale to a focused ADR set and leave behavioral contracts with code, help, and tests. Refs atqamz/secondhand#163 --- AGENTS.md | 17 +- CONTRIBUTING.md | 6 +- README.md | 24 +- SPECS.md | 2682 ----------------- cmd/launch.go | 2 +- cmd/notify.go | 5 +- cmd/precondition.go | 5 +- cmd/project.go | 2 +- cmd/root.go | 7 +- cmd/status.go | 3 +- docs/adr/README.md | 56 +- ...cts-upstream-is-declared-never-inferred.md | 48 - ...eport-channel-is-parked-on-three-bounds.md | 70 - ...that-never-lands-leaves-a-durable-trace.md | 73 - ...nt-context-is-a-session-hook-not-a-file.md | 53 +- ...-unrecorded-pr-is-recovered-by-head-ref.md | 70 - ...e-the-status-file-and-ship-no-hand-dump.md | 52 - ...mes-the-state-instead-of-widening-force.md | 61 - ...ead-no-mistakes-output-not-its-database.md | 65 +- .../harness-templates-launch-interactively.md | 52 +- ...dr-workspace-labels-carry-a-hand-prefix.md | 56 - docs/adr/holds-are-their-own-table.md | 67 +- ...onfirmation-trusts-herdr-not-the-screen.md | 77 - ...a-filtered-consumer-of-the-event-stream.md | 85 - .../one-stateful-fake-per-external-tool.md | 57 +- ...cher-per-fleet-home-guarded-by-an-flock.md | 46 +- ...is-toon-by-default-and-json-is-retained.md | 47 +- ...and-rebuilds-firstmate-as-one-go-binary.md | 77 +- ...he-collision-guard-keys-on-the-lease-id.md | 59 - ...e-comment-rule-is-two-mechanical-checks.md | 90 - ...tore-is-an-uncapped-append-only-sibling.md | 69 +- ...ork-guard-reads-the-work-not-the-record.md | 80 +- ...port-channel-is-the-only-outcome-signal.md | 57 +- ...rt-offset-is-trusted-only-with-a-digest.md | 71 - ...ma-version-lives-in-pragma-user-version.md | 65 +- .../the-until-event-exit-is-the-delivery.md | 70 +- .../the-watcher-persists-what-it-announces.md | 69 - ...limit-detection-is-a-harness-capability.md | 58 +- internal/agentsmd/agentsmd.go | 14 +- internal/agentsmd/agentsmd_test.go | 25 + internal/axi/axi.go | 3 +- internal/faketool/FIDELITY.md | 4 +- internal/harness/harness.go | 2 +- internal/herdr/types.go | 5 +- internal/project/gaterun.go | 2 +- internal/project/project.go | 2 +- internal/store/hold_test.go | 5 +- internal/watcher/events.go | 10 +- internal/watcher/watcher.go | 12 +- internal/worktree/worktree.go | 3 +- tests/e2e/send_test.go | 2 +- 51 files changed, 220 insertions(+), 4422 deletions(-) delete mode 100644 SPECS.md delete mode 100644 docs/adr/a-fork-projects-upstream-is-declared-never-inferred.md delete mode 100644 docs/adr/a-silent-report-channel-is-parked-on-three-bounds.md delete mode 100644 docs/adr/a-steer-that-never-lands-leaves-a-durable-trace.md delete mode 100644 docs/adr/an-unrecorded-pr-is-recovered-by-head-ref.md delete mode 100644 docs/adr/believe-the-status-file-and-ship-no-hand-dump.md delete mode 100644 docs/adr/deliver-names-the-state-instead-of-widening-force.md delete mode 100644 docs/adr/herdr-workspace-labels-carry-a-hand-prefix.md delete mode 100644 docs/adr/launch-confirmation-trusts-herdr-not-the-screen.md delete mode 100644 docs/adr/notify-is-a-filtered-consumer-of-the-event-stream.md delete mode 100644 docs/adr/the-collision-guard-keys-on-the-lease-id.md delete mode 100644 docs/adr/the-comment-rule-is-two-mechanical-checks.md delete mode 100644 docs/adr/the-report-offset-is-trusted-only-with-a-digest.md delete mode 100644 docs/adr/the-watcher-persists-what-it-announces.md diff --git a/AGENTS.md b/AGENTS.md index a38deca..1db216c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,21 +2,18 @@ `hand` is a CLI that manages a fleet of coding agents from a fleet home (`data/`, `state/`, this file). This checkout is the tool's own source, not a fleet home itself - there is no `state/hand.db` here, so `internal/home.IsHome` reports false. -`internal/agentsmd`'s `generatedBody` constant is the authoritative template `hand init` writes and `hand update` refreshes into every real fleet home's `AGENTS.md`; see SPECS.md's "hand doctor" section for the checks that hold it in place, and `hand --help` for the command reference. +`internal/agentsmd`'s `generatedBody` is the authoritative fleet-home template; its tests own refresh and `hand doctor` behavior, and `hand --help` is the command reference. ## Rules -- SPECS.md carries only the contract a caller can depend on; the reasoning behind a clause lives in a dated record under `docs/adr/`, reached from that section's `Why:` line. `docs/adr/README.md` owns when a record is written and the rule that a landed one is never rewritten to match a later change. -- Comments obey two rules `make lint` enforces through `tools/commentlint`: a comment may not open with the identifier it documents, and a comment block may not exceed three lines. CONTRIBUTING.md's "Comments" section owns the bar for writing one at all, the exemptions, and the reasoning. -- Command output goes through `internal/axi` as TOON and every failure through `cmd/root.go`'s error document; `hand watch`'s event stream is the one exception, and SPECS.md's "Output shape" section owns the contract. -- Harness/herdr syntax, exit-code enforcement, watch's stdout/errOut split, and first-run prompt handling are commented at point of use (`internal/herdr`, `internal/harness`, `cmd/root.go`, `cmd/precondition.go`, `internal/watcher`, `cmd/teardown.go`, `cmd/prdetect.go`, `cmd/merge.go`, `cmd/launch.go`); SPECS.md's "Exit codes" and each command's spec section own the authoritative tables. -- `herdr`, `treehouse` and `gh` are faked once, in `internal/faketool`, for every suite; a test declares the fleet it wants rather than writing sh. `internal/faketool/FIDELITY.md` records what the real tools do and `tests/contract` (`make contract`) rechecks that record against them. Extend the shared fake, never hand-write another; SPECS.md's "Testing strategy" owns the rule behind it. +- Behavioral contracts belong beside their implementation, command help, and focused tests. `docs/adr/README.md` owns the narrow bar for durable architectural rationale. +- Comments obey two rules `make lint` enforces through `tools/commentlint`: a comment may not open with the identifier it documents, and a comment block may not exceed three lines. CONTRIBUTING.md's "Comments" section owns the bar, exemptions, and reasoning. +- Command output goes through `internal/axi` as TOON and every failure through `cmd/root.go`'s error document; `hand watch`'s event stream is the exception. Package and command tests own these shapes. +- Harness/herdr syntax, exit enforcement, watch's stdout/errOut split, and first-run prompt handling are owned by their implementations and closest tests under `internal/harness`, `internal/herdr`, `internal/watcher`, and `cmd`. +- `herdr`, `treehouse` and `gh` are faked once in `internal/faketool` for every suite. `internal/faketool/FIDELITY.md` records observed external behavior and `tests/contract` (`make contract`) rechecks it. Extend the shared fake, never hand-write another. - Test, release, and write conventions live as doc comments: `tests/e2e` (`fakes_test.go`, `e2e_test.go`), GitHub access via `gh` (`internal/ghutil`), AGENTS.md refresh (`internal/agentsmd`), atomic writes (`internal/atomicfile`). - Dev environment is Nix-based (`flake.nix`, `CONTRIBUTING.md`); `make lint`, `go build ./...`, and `go test -race ./...` verify inside `nix develop`. ## Maintaining this file -Keep this file for knowledge useful to almost every future agent session in this project. -Do not repeat what the codebase already shows; point to the authoritative file or command instead. -Prefer rewriting or pruning existing entries over appending new ones. -When updating this file, preserve this bar for all agents and keep entries concise. +Keep knowledge useful to almost every future agent session. Point to authoritative code, tests, or commands instead of repeating them, and prefer rewriting or pruning existing entries over appending new ones. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efa31d7..07ae29b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,9 +52,9 @@ Exempt from both rules: the package doc comment, directives (`//go:build`, `//go Rule 2 will occasionally be wrong, because a genuinely subtle invariant sometimes needs a fourth line. That is accepted: a rule that is right most of the time and mechanically enforced binds harder than one that is right always and enforced never. -Prose that outgrows three lines belongs in one of two files, and which one depends on what it is. -A rule a caller can depend on or be wrong about goes in SPECS.md. -The reasoning for why a rule is shaped that way goes in a record under docs/adr/, whose README.md owns when one is worth writing and how SPECS.md points at it. +Behavior a caller depends on belongs with its implementation, command help, and focused tests. +User or contributor guidance belongs in README.md, AGENTS.md, or this file. +`docs/adr/README.md` owns the narrower bar for durable architectural rationale. `go run ./tools/commentlint .` runs the check alone and prints one `file:line:column` per violation. diff --git a/README.md b/README.md index 6dd17d0..9c7d7b6 100644 --- a/README.md +++ b/README.md @@ -41,14 +41,14 @@ Set `HAND_HOME` to run `hand` from outside the fleet home, for example from a sc - **Projects**: git repositories cloned under `projects/`, registered in `hand`'s machine state and projected to `data/projects.md`. Each has a delivery mode: `no-mistakes`, `direct-pr`, or `local-only`. - **Tasks**: units of work identified by a unique ID. Ship tasks produce a branch and PR; scout tasks investigate and produce `data//report.md`. - **Briefs**: task instructions at `data//brief.md`, written by the supervisory agent before spawning a worker. -- **herdr tabs**: each worker runs in its own herdr tab. herdr provides semantic agent state (working/idle/blocked/done/unknown) and push events, so no terminal scraping. herdr's state says whether a pane is busy, not whether a task finished - see SPECS.md's "Agent state" section. -- **Report channel**: `state/.status` is an append-only file the worker writes and `hand` only reads. It carries the task outcome herdr cannot (working/paused/blocked/needs-decision/done/failed), surfaces in `hand status` and `hand watch`, and auto-records a PR URL the worker reports - see SPECS.md's "Report channel" section. +- **herdr tabs**: each worker runs in its own herdr tab. herdr provides semantic agent state (working/idle/blocked/done/unknown) and push events, so no terminal scraping. Herdr state says whether a pane is busy, not whether a task finished. +- **Report channel**: `state/.status` is an append-only file the worker writes and `hand` only reads. It carries the task outcome herdr cannot (working/paused/blocked/needs-decision/done/failed), surfaces in `hand status` and `hand watch`, and auto-records a PR URL the worker reports. - **treehouse worktrees**: workers operate in isolated git checkouts acquired from a treehouse pool, never in the project clone itself. - **Backlog**: `data/backlog.md` is a plain markdown task queue, read and edited directly by the supervisory agent. Finished entries roll off into `data/done-archive.md`, dropped ones into `data/note-archive.md`. - **Operator context and learnings**: `data/operator.md` is written by the operator for the agent to read first - identity, authority, hard constraints - and `data/learnings.md` is the agent's own curated record of operational facts that cost real time to discover. The agent reads `data/operator.md` and never rewrites it, which is what lets its constraints outrank the agent's judgment. `hand init` seeds both, `hand update` seeds whichever an older home is missing, and neither ever overwrites one that exists; nothing under `data/` is maintained by hand for the operator to read, since `hand status` and the issue tracker are their view of the fleet. -- **Ambient context**: `hand init` and `hand update` install `hand` as a Claude Code `SessionStart` hook in the home's `.claude/settings.json`, so a supervising session opens with the fleet overview already in context instead of spending a turn asking for it. The file is merged, never overwritten: an operator's own hooks and permissions survive every refresh, and hand owns at most one entry - see SPECS.md's "Ambient context" section. -- **Agent-shaped output**: every command prints TOON on stdout - `key: value` fields, `name[N]{f1,f2}:` row blocks with pre-computed aggregates above them, and a `help[N]:` list of what to run next - because the consumer is an LLM agent rather than a human terminal, with `hand watch`'s per-line event stream as the one exception. `--fields` narrows a row block to the columns you name, `--json` still returns the same object it always did, and a failure renders its own document on stderr carrying `error`, `kind` and `exit` so a caller branches on a word instead of a number - see SPECS.md's "Output shape" section. -- **Machine state vs. the prose corpus**: machine state - tasks, PR state, pane ids, the project registry, holds - is authoritative in sqlite at `state/hand.db`. The prose under `data/` stays authoritative in files, with a derived full-text index at `state/index.db` that `hand search` reads and that is safe to delete at any time. When the database and a `state/.status` file disagree about what a worker said, believe the file: it is readable without a working `hand`, which is what recovery has actually needed - see SPECS.md's "Machine state and the prose corpus" section. +- **Ambient context**: `hand init` and `hand update` install `hand` as a Claude Code `SessionStart` hook in the home's `.claude/settings.json`, so a supervising session opens with the fleet overview already in context instead of spending a turn asking for it. The file is merged, never overwritten: an operator's own hooks and permissions survive every refresh, and hand owns at most one entry. +- **Agent-shaped output**: every command prints TOON on stdout - `key: value` fields, `name[N]{f1,f2}:` row blocks with pre-computed aggregates above them, and a `help[N]:` list of what to run next - because the consumer is an LLM agent rather than a human terminal, with `hand watch`'s per-line event stream as the one exception. `--fields` narrows a row block to the columns you name, `--json` retains its existing object, and a failure renders its own document on stderr carrying `error`, `kind` and `exit` so a caller branches on a word instead of a number. +- **Machine state vs. the prose corpus**: machine state - tasks, PR state, pane ids, the project registry, holds - is authoritative in sqlite at `state/hand.db`. The prose under `data/` stays authoritative in files, with a derived full-text index at `state/index.db` that `hand search` reads and that is safe to delete at any time. When the database and a `state/.status` file disagree about what a worker said, believe the file: it is readable without a working `hand`, which is what recovery has actually needed. ## CLI overview @@ -166,7 +166,7 @@ Builds without an embedded version never print the notice. ## Configuration -Preferences live as plain files under `config/`, one value per file; SPECS.md's "Directory layout" section lists every key `hand` reads. +Preferences live as plain files under `config/`, one value per file. The three worker defaults - `harness`, `model` and `effort` - are owned by `hand config`, which validates a value and writes it atomically: @@ -182,7 +182,17 @@ The harness comes first because it decides whether the other two exist at all: ` Nothing sets these for you. `hand init` reports their state, every supervising session's opening document repeats the report, and the answer is yours to give in that session - the fleet home's own `AGENTS.md` carries the instructions the agent follows to ask. -A brief can declare its own `model` and `effort` for one task, which win over these defaults and lose only to a `hand spawn`/`hand promote` flag - see SPECS.md's "Brief format" section. +A brief can start with a `---` fenced block declaring `model` and `effort` for one task. Those values win over fleet defaults and lose only to a `hand spawn` or `hand promote` flag. + +Other optional files tune supervision: + +- `notify`: shell command template run with `HAND_MESSAGE` set; `hand notify` reports an error when it is absent. +- `send-wait`: how long `hand send` waits for a busy composer, as a Go duration (default `2m`). +- `watch-interval`: watcher poll interval, as a Go duration (default `5s`). +- `stale-threshold`: seconds without an agent-state transition before `stale` (default `300`). +- `parked-paused-bound`: seconds of report-channel silence after `paused` (default `3600`). +- `parked-done-bound`: seconds of silence after `done` or `failed` (default `5400`). +- `parked-other-bound`: seconds of silence in every other report state (default `1200`). Workers run their harness interactively so they can be steered and watched. For Claude Code that means first-run dialogs, and `hand spawn` and `hand promote` answer the workspace-trust and bypass-permissions ones for you, then confirm the worker is actually running before reporting success. diff --git a/SPECS.md b/SPECS.md deleted file mode 100644 index cc0f53e..0000000 --- a/SPECS.md +++ /dev/null @@ -1,2682 +0,0 @@ -# Secondhand - -Talk to one agent. Ship with a crew. - -CLI: `hand` -Language: Go -Default backend: herdr -No persona, no role-play. Pure functionality. - -## What this file is - -`hand` orchestrates a fleet of coding agents from a fleet home. -A supervisory agent records tasks in a markdown backlog and calls `hand` to spawn autonomous workers into isolated git worktrees. - -This file is the contract: what a caller can depend on and be wrong about. -The reasoning behind it lives in dated records under `docs/adr/`, and a section with a record behind it ends with a `Why:` line naming it. -A section with no `Why:` line is one nothing was decided about: a file format, or a layout that is what it is. -`docs/adr/README.md` states when a record is written and that a landed one is never edited. - -Why: `docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md`. - -## Core principles - -1. **One binary owns orchestration.** The agent calls CLI commands. The CLI owns lifecycle correctness, state management, and process supervision. No shell scripts. -2. **AGENTS.md stays tiny.** Target ~25 lines of rules. The CLI's `--help` carries operational detail. If the agent needs 500 lines of instructions to operate the tool, the tool is wrong. -3. **herdr-native.** herdr provides semantic agent state (working/idle/blocked/done/unknown) and push events. Use them instead of regex-scraping terminal output. herdr's own agent state carries no task-outcome signal (see "Agent state"); the report channel is what actually tells hand whether a task finished. -4. **Text editing stays with the agent.** The backlog is a markdown file. The agent reads and edits it directly. No CLI wrapper for text operations. -5. **No feature without friction.** Every feature in firstmate that doesn't have a proven use case is cut. Features get added when their absence causes real pain. -6. **A fleet home is any directory `state/hand.db` marks as one.** `hand init` creates the file up front; put `hand` on PATH and launch the agent there. Only `hand` ever writes it, so a project clone under `projects/` carrying its own generic top-level `data/` and `state/` cannot capture the walk up. A home initialized before `state/hand.db` existed falls back to the marker it was initialized with, `data/projects.md` plus `state/`, so an operator upgrading in place never has to re-run anything by hand and the legacy `state/.json` import still finds a home to run against (see "Migration"). Maintainers dogfood a fleet home inside the secondhand repo checkout itself, with runtime state gitignored alongside the tracked code, but the CLI has no opinion about the two: `HAND_HOME`, or an ancestor of the working directory, is all it looks for. -7. **`hand status` is the memory.** It is computed from the store and the report channel at the moment it is asked for, never a file the agent or the user reads out of band. No session digests, no bootstrap scripts, no 187-line status dumps, and no rendering that can disagree with the state behind it (atqamz/secondhand#62). -8. **No hooks, no guards, no callbacks.** The CLI fails closed on bad operations. Errors are CLI output, not injected hook messages. The agent reads errors and decides. No magic. The one hook `hand` installs is the opposite of a guard: a `SessionStart` entry that runs the bare command so a session opens with the fleet in context, policing nothing and refusing nothing (see "Ambient context"). - -Why: `docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md`, whose rejected alternatives include porting firstmate's feature surface across, and why principle 5 cuts it instead. - -## Architecture overview - -``` - user - | chat: requests, decisions, "merge it" - v - +---------------------------+ - | supervisory agent | - | reads AGENTS.md + data/ | - | edits data/backlog.md | - | calls `hand` commands | - +--+----------+----------+--+ - | | | - v v v - [task-1] [task-2] [task-N] herdr tabs - [worker] [worker] [worker] one autonomous agent each - | | | - v v v - treehouse worktrees (isolated git checkouts) - | - +-- ship: branch -> PR -> merge -> teardown - | - +-- scout: investigate -> report.md -> teardown -``` - -The supervisory agent is any supported harness (claude, codex, pi, grok, opencode) launched inside a fleet home. -Workers are autonomous agents launched by `hand spawn` into herdr tabs with treehouse worktrees; they follow the brief and report through `state/.status` (see "Report channel"). - -Why: `docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md`, `docs/adr/believe-the-status-file-and-ship-no-hand-dump.md`. - -## Machine state and the prose corpus - -A fleet home holds two kinds of state, and they have different owners. - -**Machine state is authoritative in sqlite**, at `state/hand.db`: the task registry, PR state, herdr pane and tab ids, `hand watch`'s report offsets, the project registry, and holds (see `hand hold set` and "Holds" under "State management"). -It is what `hand` writes and what `hand` reads back. -Nothing derives it from a file, and no view is assembled by re-reading a rendering of it. - -**The prose corpus is authoritative in files**, under `data/`: briefs, scout reports, the backlog's prose, and whatever else a human or a worker writes there. -sqlite holds only a *derived* index of it, at `state/index.db`, for `hand search`. - -Three rules keep the derived half honest: - -1. **A corrupt index costs a rebuild and never the corpus.** `state/index.db` can be deleted outright at any time; the next `hand search` rebuilds it, and `hand search --rebuild` forces the rebuild against an index that is present but wrong. -2. **Nothing may depend on the index being correct in order to recover.** The index is read by exactly one command, and the rebuild reads the corpus directly, never the index and never machine state - a `state/hand.db` that is missing or unreadable does not stop a supervisor from searching their way back to what the fleet was doing. -3. **Prose bodies are not schematized.** The index holds a path, an mtime, a size, a title and the full text for matching. The body stays the file's, and no command parses a brief into columns. - -### Which to believe when they disagree - -`state/.status` - the worker-to-supervisor report channel - survives this design unconditionally, and it is a projection, not an input. -The database never holds a second copy of it. - -**When the database and a `.status` file disagree about what a worker said, believe the file.** -There is deliberately no `hand dump`. - -The database is authoritative for everything the file does not carry - what `hand` recorded, decided, or observed - which is most of machine state. -The file is authoritative for what the worker said. - -Why: `docs/adr/believe-the-status-file-and-ship-no-hand-dump.md`. - -### Not Postgres, and no daemon - -sqlite in rollback journal mode, one short-lived process per command, one writer at a time. -No server, no connection pool, no background process holding the database open. -`hand watch` is still the only long-running process, and it holds no database lock between ticks - only the `flock` that makes it the fleet home's single watcher (see "One watcher per fleet home"). -A fleet home therefore stays a directory that can be copied, backed up and inspected with ordinary tools. - -## Directory layout - -A standalone fleet home has no tracked section: `hand init` lays down only the gitignored -runtime below, and `hand` runs against it from wherever it lives on disk. The tracked section -exists because secondhand's own maintainers dogfood a fleet home inside the repo checkout, -alongside the code that implements it. - -``` -secondhand/ # maintainer's in-repo fleet home = repo checkout - # tracked (committed) - main.go # entry point - cmd/ # cobra command implementations - root.go - init.go - spawn.go - status.go - send.go - teardown.go - merge.go - pr.go - watch.go - project.go - promote.go - search.go - notify.go - internal/ - home/ # fleet home definition and resolution - home.go # IsHome marker check, HAND_HOME/ancestor-walk Resolve - herdr/ # herdr client library - client.go # API calls: create tab, get state, send keys - types.go # herdr data types - store/ # machine state in sqlite (see "Machine state and the prose corpus") - store.go # schema, task, project and hold rows, meta keys - schemaversion.go # PRAGMA user_version gate and registered ALTER TABLE steps - lock.go # named flocks over state/, shared by state, the import and the schema migration - migrate.go # one-way import of pre-sqlite state/.json and data/projects.md - index.go # derived full-text index over the prose corpus - state/ # task state management, a thin facade over store - task.go # read/write/list task rows - hold.go # set/clear/read/list hold rows (see "Holds") - types.go # Task and Hold struct aliases - report.go # read/classify state/.status (see "Report channel") - pr.go # PR URL validation and extraction - worktree/ # treehouse integration - worktree.go # get, return, collision check - brief/ # brief parsing - brief.go # read the brief's declared model/effort (see "Brief format") - watcher/ # fleet supervision - watcher.go # poll/push event loop - events.go # event classification - usagelimit.go # detect, resume and release a quota-limited worker (see "Resuming a usage-limited worker") - ownership.go # flock on state/watch.pid (see "One watcher per fleet home") - notify/ # out-of-band delivery - notify.go # config/notify template execution, shared by hand notify and the watcher's in-process hook - project/ # project registry - project.go # add, list, remove, resolve - pr.go # shared PR validation: repo-slug match, gh existence check - harness/ # agent launch templates - harness.go # per-harness launch command construction - usagelimit.go # per-harness usage-limit signatures and reset parsing (claude only) - completion/ # durable teardown completion record - completion.go # append/list state/completions.jsonl - agentsmd/ # generated AGENTS.md template - agentsmd.go # write and refresh the generated span, check a home's file (see "`hand doctor`") - age/ # elapsed-time strings shared by hand status and the watcher - age.go # FormatAge, FormatDuration - atomicfile/ # shared write-to-temp-then-rename helper - atomicfile.go # atomic file replacement - axi/ # the one TOON renderer every command emits through (see "Output shape") - axi.go # fields, row blocks, --fields selection, truncation hints, help[] lines - sessionhook/ # ambient context for a supervising session (see "Ambient context") - sessionhook.go # install, repoint and report the SessionStart hook entry - tools/ - commentlint/ # the comment check `make lint` and CI run (see "Repo scaffolding") - main.go # walk the tree, report one file:line:column per violation - go.mod - go.sum - AGENTS.md # agent instructions (~25 lines of rules) - CLAUDE.md -> AGENTS.md # symlink for Claude Code compatibility - README.md - LICENSE - .gitignore - - # gitignored runtime (created by `hand init`) - state/ # machine state, the report channel, and the durable completion store - hand.db # authoritative machine state: tasks, PR state, pane ids, report offsets, projects, holds - index.db # derived full-text index over data/, safe to delete at any time - migrated/ # pre-sqlite state/.json files, moved aside once imported - .status # worker-to-supervisor report channel, worker-written, hand-read-only - events.log # recent watcher events, bounded rotating log - watch.pid # the flock the fleet home's single watcher holds (see "One watcher per fleet home") - completions.jsonl # durable teardown completion records, one JSON object per line, uncapped - data/ - operator.md # standing operator constraints and preferences, read at session start - backlog.md # plain markdown task queue, agent-edited - projects.md # project registry projection (see "Project registry format") - learnings.md # dated, evidence-backed operational facts, curated not appended - done-archive.md # finished entries rolled off backlog.md (see "Backlog format") - note-archive.md # dropped or superseded backlog entries, kept with the reason - / # per-task data directory - brief.md # task instructions written by the supervisory agent - report.md # scout task deliverable, written by the worker - projects/ # git clones, read-only to supervisory agent - config/ # local user preferences (optional) - harness # default harness for workers (default: claude) - model. # default model for workers under that harness (optional) - effort. # default effort for workers under that harness (optional) - notify # notification command template (optional) - stale-threshold # seconds before a task is considered stale (default: 300) - watch-interval # poll interval for `hand watch` (default: 5s) - send-wait # how long `hand send` waits for a busy composer (default: 2m) - parked-paused-bound # seconds a paused-and-silent task may sit before it's parked (default: 3600) - parked-done-bound # seconds a done-or-failed-and-silent task may sit before it's parked (default: 5400) - parked-other-bound # seconds a silent task in any other state may sit before it's parked (default: 1200) - .claude/ - settings.json # merged, never overwritten: hand owns one SessionStart entry in it -``` - -Who writes a file for whom is what decides whether it belongs under `data/` at all. -`operator.md` is the operator writing for the agent - identity, authority, and hard constraints that outrank the agent's judgment. -`backlog.md`, `learnings.md` and the two archives are the agent writing for itself, and `projects.md` is the one file here that is a projection of machine state rather than prose either of them authored (see "Project registry format"). -The direction `data/` does not carry is a file maintained by hand for the operator to read: `hand status` and the issue tracker answer what the fleet is doing and what is waiting on a human, so a decision taken under a standing autonomy grant goes on the PR or issue it concerns rather than into a fleet file. -`hand init` seeds `operator.md`, `learnings.md`, `done-archive.md` and `note-archive.md` and no command ever overwrites one that exists; `hand update` seeds whichever of them the home is missing (see "Self-update: `hand update`"). -`learnings.md`, `done-archive.md` and `note-archive.md` are plain agent-edited markdown with no schema, no subcommand and no validation, the same treatment `backlog.md` already gets. -`operator.md` gets that same treatment minus the editing: it is the operator's file, which the agent reads at session start and never rewrites. - -Why: `docs/adr/believe-the-status-file-and-ship-no-hand-dump.md`. - -## Ambient context - -`hand init` and `hand update` install `hand` as a Claude Code `SessionStart` hook in the home's -`.claude/settings.json`, so every conversation starts with the bare command's overview - identity, -home, counts and the task table - already in context. - -The file is merged, never overwritten. An operator's permissions, other events and other -`SessionStart` entries are carried through untouched, and a `settings.json` hand cannot parse is an -error rather than a clobber. Hand owns at most one entry: the first whose command runs this binary -or any binary named `hand`. Refreshing repoints that entry's path and leaves any arguments the -operator added to it alone. - -Installing is confined to a fleet home. A directory with no `state/hand.db` gets no `.claude/` -directory at all. - -### First-run configuration - -The generated AGENTS.md workflow settles any worker default the session overview's `config` block reports -`missing` before dispatching anything. Its first-run contract is: - -The session overview's `config` block is what this fleet dispatches with, and every value in it is the operator's to choose. - -- A setting reported `missing` is a question the supervisor asks the operator in conversation, in plain words, offering what `hand config` lists as supported and installed. It persists the answer with `hand config set `, which validates and writes atomically; it never writes under `config/` itself. -- The supervisor never answers one of these for the operator. A value it picked, or one its own harness dialog accepted, configures the fleet with a guess that afterwards looks exactly like the operator's decision. -- `hand config set` reprints the block, so the supervisor reads what it returns and keeps asking until nothing is `missing`. `hand config` re-reads it at any time. -- `unsupported` means the selected harness takes no such launch flag, so it is not a question and not a gap. `pending-harness` means applicability is unknown until the harness is chosen, so that is chosen first. -- An operator who declines to answer has answered: the supervisor leaves it missing, says so, and carries on with everything that does not need it. The next session asks again. - -Why: `docs/adr/ambient-context-is-a-session-hook-not-a-file.md`. - -## Output shape - -Every command's default output is TOON (https://axi.md) rather than a table aligned for a human terminal. -`internal/axi` is the single renderer every command emits through, so the shape below is a property of that package and not a convention each command re-implements. - -Three block kinds, rendered in the order the command adds them: -- A scalar field, `key: value`. -- A row block, `name[N]{f1,f2,f3}:` followed by one two-space-indented comma-joined row per item. -- A list block, `name[N]:` followed by one ` - item` line per item. - -The rules that hold across every command: -- A row block prints its `name[N]{...}:` header even when `N` is zero, so an empty result is a positive statement carrying a count rather than silence. -- A value is quoted, in Go string-literal syntax, only when it would otherwise be ambiguous: it carries a `,`, a `:` or a `"`, it has leading or trailing whitespace, or it is empty. The empty string renders as `""`, since unquoted it is indistinguishable from a field nobody emitted. -- A field with nothing behind it renders as `none`, so "no PR recorded" never reads as a PR whose URL happens to be blank. -- A truncated field carries its own recovery: `... (truncated, chars total - use to see complete text)`, naming the command that returns the whole field. The budget is counted in runes, never bytes, so a multi-byte character is never cut in half. -- A list item runs to end of line, so an embedded newline is collapsed to a space rather than silently becoming a second item. -- A `help[N]:` block, when present, is last, and names what to run next. It is omitted entirely when a command has nothing to suggest - an empty `help[0]` block costs context and says nothing. -- Counts and other aggregates are pre-computed and emitted as scalar fields above the rows they summarize, so a caller that only needs the number never has to count rows to get it. -- A command that changes something confirms it with a `result:` field naming what happened, alongside the fields it changed. One field carries the outcome, so `recorded` and `already-recorded`, or `merged` and its method, are told apart by reading a value rather than matching a sentence. -- `--fields ` narrows a command's row block to the named columns, in the order named, and the schema header narrows with the rows: a header promising columns the rows do not carry is worse than no header at all. An unknown name is a usage error (exit 2) naming the whole vocabulary, never a silently narrower result. ` --help` lists every field name. -- `--json` is retained everywhere it existed, byte for byte unchanged. `--fields` narrows the TOON schema only, so combining it with `--json` is a usage error rather than a silently ignored request. - -`hand watch` is the one command outside this contract: its stdout is a per-line event stream a supervisory agent tails as it arrives, so it stays a line protocol (see "`hand watch`"). - -Why: `docs/adr/output-is-toon-by-default-and-json-is-retained.md`. - -## CLI specification - -### `hand` - -The bare command introduces the binary and reports the fleet in one document, rather than printing the help screen cobra defaults to. -`hand --help` is the command reference. - -``` -tool: hand -purpose: manages a fleet of coding agents - one worker per task in its own worktree and herdr pane -version: 0.1.4 -exec: ~/.local/bin/hand -home: ~/secondhand -config_missing: 1 -config[3]{key,state,value}: - harness,configured,claude - model,missing,none - effort,missing,none -count: 2 -attention: 1 -held: 0 -tasks[2]{id,state,reported,age,flags}: - fix-login,working,working,2h ago,none - audit-deps,done,done,20m ago,unacknowledged -holds[0]{id,kind,detail,age}: -help[5]: - - Ask the operator for the default model for claude workers, then run `hand config set model ` - - Ask the operator for the default effort for claude workers, then run `hand config set effort ` - - Run `hand status ` for one task's detail and report history - - A flagged row is waiting on you: `hand send ` to steer it, `hand hold set --kind operator --reason ` to park it - - Run `hand status --fields ` to pick columns, `hand status --help` for every field name -``` - -`exec` names the executable that answered, with the user's home abbreviated to `~`. -The configuration block precedes the fleet overview and leads `help` with one question per applicable missing value. -Everything from `count` down is `hand status`'s fleet overview with its default fields, built by the same code, so the two can never disagree. - -Outside a fleet home the identity fields still print, `home` is `none`, and the help block names the way in: - -``` -tool: hand -purpose: manages a fleet of coding agents - one worker per task in its own worktree and herdr pane -version: 0.1.4 -exec: ~/.local/bin/hand -home: none -help[2]: - - Run `hand init` in the directory that should become the fleet home, or point HAND_HOME at one that already exists - - Run `hand --help` for the command reference -``` - -Exit `0`, unlike every other command's `3` for an unresolvable home. - -### `hand init [path]` - -Initialize secondhand runtime directories in the current working directory. -Creates `state/`, `data/`, `projects/`, `config/` if they don't exist. -Creates `data/backlog.md`, `data/projects.md`, `data/operator.md`, `data/learnings.md`, `data/done-archive.md` and `data/note-archive.md` with skeleton content, and creates `state/hand.db` if it does not already exist - the fleet-home marker `IsHome` checks for (see "Core principles"). -A skeleton is written only when the file is absent, so re-running `hand init` in an existing home picks up a file the layout gained since it was initialized and never loses what is in one. -`hand update` seeds the same skeletons the same way. -Also installs the ambient-context session hook described below. -Idempotent: safe to run multiple times. -This is the one command that does not resolve its home: it creates the one its argument or the working directory names. -When `HAND_HOME` is set and names some other directory it still initializes the requested target, and warns on stderr that every other command will use `HAND_HOME` instead, naming it as the absolute path those commands resolve it to so a relative `HAND_HOME` is not mistaken for a second home. - -``` -hand init -``` - -Flags: none. - -`hand init` asks nothing and reads no stdin, so it behaves the same in a terminal, in a script, and with -stdin closed. It writes no worker default either: what the fleet dispatches with is the operator's -choice, and a value invented at bootstrap time is indistinguishable afterwards from one they made. The -document reports which defaults are still missing, and the first supervising session asks for them -(see "`hand config`"). - -Output: -``` -result: initialized -home: /path/to/fleet -agents_md: written -session_hook: written -migrated[0]: -config_missing: 1 -config[3]{key,state,value}: - harness,missing,none - model,pending-harness,none - effort,pending-harness,none -missing_tools[4]: - - treehouse - - herdr - - no-mistakes - - gh -help[4]: - - Start a supervising session in this home; it reports the worker defaults still missing and asks you for each one (`hand config set `) - - Read AGENTS.md in this home for how a supervising agent is meant to drive it - - Run `hand project add ` to register the first project - - The session integration installed here is a Claude Code `SessionStart` hook, so a session opened with another harness reads AGENTS.md itself -``` - -`agents_md` and `session_hook` are each `written` or `unchanged`, so a re-run says which of the two -it had to touch rather than going silent about both. - -`missing_tools` lists the tools from `treehouse`, `herdr`, `no-mistakes` and `gh` that are not on PATH. -It is a diagnostic, not a gate: init reports them and succeeds, because which of them a home needs -depends on the delivery mode of projects it does not have yet. - -`migrated` names the worker defaults an older home carried unkeyed (`config/model`) that were moved under -the harness they were chosen for (`config/model.claude`); it is empty for a new home and for one already -keyed. - -The `config` block is the same one `hand config` and the bare command print (see "`hand config`"). - -Errors: -- Filesystem permission errors. - ---- - -### `hand config [set ]` - -Report the fleet's worker defaults and which of them are still missing, or validate and persist one. - -``` -hand config -hand config set harness claude -hand config set model claude-opus-5 -``` - -Bare output: -``` -home: /path/to/fleet -harness: none -config_missing: 1 -config[3]{key,state,value}: - harness,missing,none - model,pending-harness,none - effort,pending-harness,none -harnesses[5]{name,installed,model,effort}: - claude,true,true,true - codex,true,true,true - grok,false,false,false - pi,false,false,false - opencode,true,true,false -help[1]: - - Ask the operator which harness this fleet's workers should default to, then run `hand config set harness `; `hand config` lists the supported ones and which are installed -``` - -`set` output adds `result: set`, `key`, `value` and `file` (the repo-relative path written), then reprints -the same block, so the answer's own document carries the recheck. - -States: `configured`, `missing` (a question still owed an answer), `unsupported` (the selected harness -takes no such launch flag, so there is nothing to configure), `pending-harness` (applicability is unknown -until a harness is chosen). - -The `harnesses` block's `model` and `effort` columns are read from the harness contract the launch path -uses, never restated, so a second table can never claim a capability `hand spawn` disagrees with. - -`help` carries one line per `missing` setting and names the operator as the one who answers: a supervising -agent that resolves the question itself has configured the fleet with its own guess. - -Errors: -- Unknown key, unrecognized harness name, a value that is not a single word, or a `model`/`effort` the - configured harness cannot carry: exit 2. -- `model` or `effort` before any harness is configured: exit 3, since the setting does not apply to - anything yet. - ---- - -### `hand project add [flags]` - -Clone a git repository into `projects/` and register it in the store. - -``` -hand project add https://github.com/org/repo -hand project add https://github.com/org/repo --mode no-mistakes -hand project add git@github.com:org/repo.git --name custom-name -``` - -Flags: -- `--mode `: delivery mode. One of `no-mistakes`, `direct-pr`, `local-only`. Default: `direct-pr`. -- `--name `: override the project name (default: derived from repo URL). - -Behavior: -1. Validate the URL is a git remote. -2. Derive project name from URL (last path segment minus `.git`), or use `--name`. -3. Refuse if a project with that name already exists. -4. `git clone` into `projects/`. -5. If `--mode no-mistakes`, run `no-mistakes init` inside the clone. -6. Initialize treehouse for the project: `treehouse init` inside the clone if no `treehouse.toml` exists. - Then list `treehouse.toml` in the clone's `info/exclude`. -7. Add the project to the store and rewrite the `data/projects.md` projection. -8. If clone or init fails, clean up partial state (remove the clone dir, don't append to registry). - -Output: -``` -name: nsr -result: added -mode: direct-pr -url: "https://github.com/yes2games/nsr" -clone: /home/user/fleet/projects/nsr -help[1]: - - Run `hand spawn nsr` to dispatch a worker into it -``` - -Errors: -- URL is not a valid git remote. -- Project name already registered. -- Clone fails (auth, network). -- no-mistakes init fails. - ---- - -### `hand project list` - -List registered projects from the store. - -``` -hand project list -hand project list --fields name,mode -hand project list --json -``` - -Flags: -- `--fields `: which columns the `projects` block emits, in the order given. Any of `name`, `mode`, `url`, `upstream`, `gate`; all five by default. An unknown name is a usage error (`2`) naming the known ones. -- `--json`: the raw registry objects, unchanged from earlier versions. Rejects `--fields` as a usage error (`2`) rather than silently handing back the full object the caller asked to narrow. - -Output: -``` -count: 3 -gate_issues: 1 -projects[3]{name,mode,url,upstream,gate}: - nsr,direct-pr,"https://github.com/yes2games/nsr",none,none - yes2infra,no-mistakes,"https://github.com/yes2games/yes2infra",none,not initialized - no-mistakes,direct-pr,"https://github.com/atqamz/no-mistakes",kunchenguid/no-mistakes,none -help[2]: - - Run `hand spawn ` to dispatch a worker into one of these - - A project with a gate value cannot honour its no-mistakes mode until that is fixed; `hand doctor` and the project's own clone are where to look -``` - -Output (JSON): -```json -[ - {"name": "nsr", "url": "https://github.com/yes2games/nsr", "mode": "direct-pr"}, - {"name": "yes2infra", "url": "https://github.com/yes2games/yes2infra", "mode": "no-mistakes", "gate_issue": "not initialized"}, - {"name": "no-mistakes", "url": "https://github.com/atqamz/no-mistakes", "mode": "direct-pr", "upstream": "kunchenguid/no-mistakes"} -] -``` - -Every column carries a value on every row: a project with no declared upstream reads `none` rather -than dropping the cell. JSON keeps its omit-when-absent shape (`upstream`, `gate_issue`). - -A `no-mistakes`-mode project whose gate cannot currently be honoured (not initialized, or -`unreachable` - the binary itself missing, the clone path missing on disk, or the clone path -existing but not a git repository; see "Gate preflight") carries that text in its `gate` column and -its `gate_issue` field in JSON. `gate_issues` counts them, and a nonzero count adds the `help[]` line -above saying where to look. Every `no-mistakes`-mode project pays one `no-mistakes status` call per -`hand project list` invocation; other modes pay nothing. - -An empty registry is `count: 0` with the schema header and a `help[]` line naming -`hand project add`, never silence. - ---- - -### `hand project upstream ` - -Declare which repo a fork project opens its PRs against, so `hand pr` accepts a PR living there -instead of on the fork hand pushes to, and gate-opened-PR detection looks for one there as well as on -the project's own repo (see "Project registry format", and `hand status` for the detection rule). Pass -an empty `` to clear the declaration. - -``` -hand project upstream no-mistakes kunchenguid/no-mistakes -hand project upstream no-mistakes https://github.com/kunchenguid/no-mistakes -hand project upstream no-mistakes "" -``` - -Behavior: -1. Normalize `` to an `owner/repo` slug, accepting a bare slug or any remote URL form. Refuse - anything that cannot be resolved to one, and refuse a slug containing whitespace, which the - `data/projects.md` projection cannot carry (see "Project registry format"). -2. Write it onto the project's row and rewrite the `data/projects.md` projection, under the project - lock. - -Why: `docs/adr/a-fork-projects-upstream-is-declared-never-inferred.md`. - -Output: -``` -name: no-mistakes -result: upstream-set -upstream: kunchenguid/no-mistakes -``` - -Output (cleared): -``` -name: no-mistakes -result: upstream-cleared -upstream: none -``` - -Errors: -- `` cannot be resolved to `owner/repo` (usage error, code `2`). -- Project not found in registry. - ---- - -### `hand project remove ` - -Unregister a project. Does NOT delete the clone under `projects/`. -Refuses if any active task references this project. - -``` -hand project remove nsr -``` - -Output: -``` -name: nsr -result: removed -clone: /home/user/fleet/projects/nsr -help[1]: - - The clone is retained; delete it by hand if the registration was the only thing holding it -``` - -Errors: -- Project not found in registry. -- Active tasks reference this project. - ---- - -### `hand spawn [flags]` - -Spawn a worker agent in an isolated worktree. - -``` -hand spawn fix-login nsr -hand spawn fix-login nsr --harness claude -hand spawn investigate-crash nsr --scout -``` - -Flags: -- `--scout`: mark as scout task (deliverable is a report, not a PR). -- `--harness `: agent harness to launch. Default: value from `config/harness`, or `claude`. -- `--model `: model override for harnesses that support it. Default: the brief's declared `model`, else `config/model.`. -- `--effort `: effort level for harnesses that support it. Default: the brief's declared `effort`, else `config/effort.`. -- `--skip-gate-check`: dispatch into a `no-mistakes` project even if its gate is not initialized, its - clone path is missing from disk, or that path is not a git repository (see "Gate preflight"). - -Model and effort resolve most-specific-first: the flag, then the brief's `---` declaration (see -"Brief format"), then the config default, then unset. - -Anything the chosen harness cannot carry is a warning on stderr, not a failure: the spawn proceeds, -with a resolved model or effort recorded in state and ignored by the launch command. Everything a -launch drops is named on one line rather than one line each. What can be dropped: - -- a resolved effort under `grok`, `pi` or `opencode` (`harness.SupportsEffort`) -- a resolved model under `grok` or `pi` (`harness.SupportsModel`) -- the operator-decision rule, and the front-matter disclaimer when the brief has front matter, - under `grok` or `pi` (`harness.CarriesPrompt`, see "Harness launch templates") - -The line reads `warning: harness "grok" cannot carry model "opus", effort "high", the -operator-decision rule, the front-matter disclaimer; launching anyway`, listing only what that -launch actually drops. - -The tier resolves only after the project, gate preflight and brief checks have passed, so a run that -refuses names its refusal and nothing about a launch it never performs. `hand promote` resolves the -tier in that same position. - -Behavior: -1. Validate project exists in registry. -2. If the project's mode is `no-mistakes`, run the gate preflight check (see "Gate preflight"); - refuse before touching any task or worktree state if it comes back not initialized or - unreachable, unless `--skip-gate-check` is set. -3. Validate no active task with this ID exists. -4. Validate no hold is set on this ID (see "Holds" under "State management"), whatever its kind - a hold outlives the teardown of the task it was set on. The error names `hand hold clear ` as the remedy. -5. Validate `data//brief.md` exists (the agent must write it before spawning). -6. Acquire a treehouse worktree: `treehouse get --lease --json --lease-holder hand:`, run inside the project clone (treehouse resolves the pool from cwd). Both the slot path and the lease identity treehouse returns are kept, and both are recorded on the task row. -7. **Collision guard:** cross-check the acquired lease against every other task row in the store. If one matches, return the worktree to treehouse and fail with an error naming the conflicting task. See "Collision guard" under "State management" for what counts as a match, and why it is the lease identity rather than the worktree path. -8. Acquire the task's herdr tab in the project's workspace. - - Workspace and tab labels: one workspace per project, one tab per task - see "Workspace and - tab model" under "Herdr integration detail" for the labels themselves. - - If the project's workspace does not exist yet, create it at the worktree's cwd. herdr has no - way to create an empty workspace, so this reuses the root tab it creates as the task's tab, - renamed to the task ID, rather than creating a second one. - - If the workspace already exists, create a new tab in it for the task. -9. Construct the harness launch command from the template (see harness section). -10. Send the launch command to the herdr pane. -11. Confirm the worker actually started: poll the pane until herdr reports a live agent on it and - no first-run dialog is left, answering any known dialog along the way, or the poll window - elapses (see Harness launch templates). -12. Write the task's row with all metadata. - -Any failure before step 12 leaves nothing behind: the worktree lease returns to treehouse and the -herdr side is rolled back. -A workspace this command created is closed whole: the task's tab is that workspace's own -auto-created root tab, so there is nothing else in it to preserve. -A workspace that already existed is shared with other tasks, so it keeps running and only loses -the tab this command added. -Once the task's row is written the task owns its worktree and tab: nothing after that step can -tear down a task that is already running. - -Output: -``` -id: fix-login -result: spawned -project: nsr -kind: ship -harness: claude -worktree: /home/user/.treehouse/nsr-abc/1/nsr -help[2]: - - Run `hand status fix-login` to read what this worker reports - - Run `hand send fix-login ` to steer it -``` - -Errors: -- Project not registered. -- `no-mistakes` gate not initialized for a `no-mistakes`-mode project (names the exact remedy - command; see "Gate preflight"). Skipped entirely with `--skip-gate-check`. -- `no-mistakes` binary missing or not runnable, distinct from the above (see "Gate preflight"). -- Task ID already active. -- Task ID has an open hold (names `hand hold clear ` as the remedy). -- Brief not found at `data//brief.md`. -- Treehouse worktree acquisition failed (pool exhausted, git error). -- Worktree collision with another task row (names the conflicting task; see "Collision guard"). -- Herdr tab creation failed (herdr not running, session error). -- Harness not recognized. -- Worker never confirmed started within the poll window, or is waiting on a first-run prompt hand - refuses to answer (pane content and the blocking prompt included in the error). - -Task row written to the `task` table in `state/hand.db`, one column per field below: -```json -{ - "id": "fix-login", - "project": "nsr", - "kind": "ship", - "harness": "claude", - "model": "sonnet", - "effort": "low", - "worktree": "/home/user/.treehouse/nsr-abc123/1/nsr", - "brief": "data/fix-login/brief.md", - "herdr": { - "session": "default", - "workspace_id": "wA", - "tab_id": "wA:tB", - "pane_id": "wA:pC" - }, - "pr": "", - "merged": false, - "merged_at": "", - "report_offset": 0, - "report_digest": "", - "pr_merged_observed": false, - "done_verified": false, - "created_at": "2026-07-24T10:00:00Z", - "pane_started_at": "2026-07-24T10:00:00Z", - "status_changed_at": "", - "status_changed_for": "", - "parked_fired_for": "", - "usage_limit_retry_at": "", - "usage_limit_attempts": 0, - "last_report_state": "", - "last_report_note": "", - "send_undelivered_message": "", - "send_undelivered_at": "", - "lease_id": "5fe5412a4aabdeb85a148d6d73eb42d8", - "delivered_at": "", - "delivered_reason": "" -} -``` - -Why: `docs/adr/harness-templates-launch-interactively.md`, `docs/adr/the-collision-guard-keys-on-the-lease-id.md`, `docs/adr/holds-are-their-own-table.md`, `docs/adr/gate-checks-read-no-mistakes-output-not-its-database.md`. - ---- - -### `hand status [id] [flags]` - -Show fleet overview or single-task detail. - -``` -hand status -hand status fix-login -hand status --fields id,state,pr -hand status --json -hand status fix-login --full -``` - -Flags: -- `--fields `: emit these columns instead of the view's defaults, in the order named. One vocabulary serves both views, so a field means the same thing wherever it is asked for: `id`, `project`, `kind`, `harness`, `model`, `effort`, `state`, `reported`, `report`, `age`, `created`, `last_report`, `pr`, `worktree`, `herdr`, `brief`, `delivered`, `held`, `gate`, `report_file`, `flags`. See "Output shape" for the rules this flag follows everywhere. -- `--json`: output as JSON instead of TOON. Always carries the reported line and history untruncated - a machine consumer wants the whole field, and silently truncating a JSON field is a data-loss bug, not a rendering choice. -- `--full`: in the single-task view, show the reported line and history untruncated and skip the history dedup below, reproducing the pre-atqamz/secondhand#65 content exactly. - -Behavior (fleet overview): -1. List every task in the store. -2. For each, query herdr for current agent state. -3. Carry the worker's own last classified report in the `reported` column, whatever the pane is doing, and `none` when it has never reported. A pane state and a report answer different questions, so each is its own column. A report file that exists but can't be read reads `unreadable` there, and that column is never where the `unreported` flag lands - an I/O fault is not evidence the worker never reported. -4. Derive the `flags` column, one space-separated token per marker so a caller can test for one without parsing prose, and `none` when a task carries no marker at all. `unreported` requires both a not-busy pane (herdr's `idle` or `done` - see "Agent state" below) and a last word of `working` or nothing, since a busy pane that has not reported yet is not a stop anyone has to explain. `report-unreadable` is the I/O fault above. `unacknowledged` is a terminal report (`done`, `failed`) no `hand watch` has consumed; see "Unacknowledged terminal reports" below. `delivered` is a task closed out by `hand deliver`. `merged` and `merged-external` are the merge state of a recorded PR - `merged` when `hand` performed the merge, `merged-external` when `hand` only observed it, whether `hand watch`'s own `gh` poll saw it merged or gate-opened-PR detection recorded a PR that was already merged - carried whatever the agent state is, since a merged PR is a fact about the PR rather than about the pane. `gate-no-run-found` and `gate-unreachable` come from step 6. -5. Emit `count`, `attention` and `held` as scalar fields above the rows. `count` is the number of tasks, `attention` the number of them a supervisor has to look at, and `held` the number of open holds. `attention` counts a task whose report is `paused`, `blocked`, `needs-decision` or `failed`, or that carries `unreported`, `unacknowledged`, `report-unreadable` or a gate flag: the point is that a reader who only takes the aggregate learns whether anything wants them without reading a single row. -6. For a `ship` task reported `done` with a recorded PR, on a registered `no-mistakes` project: check whether that PR ever went through a gate run (see "Gate-run visibility" below), and set the `gate-` flag when it did not. The project registry read this step needs is best-effort - a registry fault leaves the check silent for every task rather than failing the whole overview over it, but prints a one-line `warning:` to stderr naming the read failure, so dropping every gate flag fleet-wide is never silent. -7. Emit the `tasks[N]{...}` block, then the `holds[N]{...}` block, then `help[N]`. Both row blocks print their header at `N` of zero, so an empty fleet is a positive statement rather than the same bare output a broken command could also produce, and a torn-down task's still-open hold is never hidden behind an absent task table. A hold names any id, not only a live task's, so it keeps appearing here after its task row is gone. A failure to read the holds fails the whole command rather than degrading to an empty list - reading no holds back must never be mistaken for nothing being held. - -The `last_report` column is the mtime of `state/.status`, and `none` when the worker has never written one. -It is not the task's age: `age` measures the task, `last_report` measures the channel. - -Output (fleet overview): -``` -count: 9 -attention: 5 -held: 2 -tasks[9]{id,state,reported,age,flags}: - fix-login,working,working,2h ago,none - dark-mode,blocked,none,45m ago,none - build-wait,working,paused,20m ago,none - stuck-task,idle,none,1h ago,unreported - paused-task,idle,needs-decision,30m ago,none - quiet-done,idle,done,15m ago,unacknowledged - investigate,done,done,10m ago,none - shipped-fix,done,done,5m ago,merged-external - no-gate-fix,done,done,3m ago,gate-no-run-found -holds[2]{id,kind,detail,age}: - fix-login,operator,"two ways to fix this, needs a call",2h ago - torn-down-task,operator,question never answered,1d ago -help[3]: - - Run `hand status ` for one task's detail and report history - - A flagged row is waiting on you: `hand send ` to steer it, `hand hold set --kind operator --reason ` to park it - - Run `hand status --fields ` to pick columns, `hand status --help` for every field name -``` - -The default columns are the five that answer "what is running and what wants me"; the rest of the vocabulary is one `--fields` away, and narrowing it narrows the schema header with it: -``` -$ hand status --fields id,pr,gate -count: 9 -attention: 5 -held: 2 -tasks[9]{id,pr,gate}: - fix-login,none,none - ... -``` - -A hold row that can't be trusted at face value - an unrecognized kind, a `blocked` hold with no `blocked_on`, or an `operator` or `limit` hold carrying one - is still emitted, never dropped, with `inconsistent: ` in place of its `detail`. - -Output (fleet overview, empty): -``` -count: 0 -attention: 0 -held: 0 -tasks[0]{id,state,reported,age,flags}: -holds[0]{id,kind,detail,age}: -help[2]: - - Run `hand project list` to see which projects are registered - - Run `hand spawn ` to start a worker -``` -The `holds[N]` block still carries any open hold when there are no tasks at all (step 7 above). - -Behavior (single task): -1. Read the task from the store. -2. If the task is a `ship` task with no PR recorded and its project is registered and not `local-only`, look for a PR to record under it, matched on head ref alone and never on title, issue number, or task id. A no-mistakes gate's own `pr` step opens a PR directly, bypassing `hand pr`, so `pr` can go unrecorded for genuinely landed work. The lookup: - 1. Searches the project's repo, and a declared `upstream` too, since a fork contribution's PR is opened on the upstream while the branch is pushed to the fork. - 2. Counts only PRs whose head branch lives in the project's own repo. An upstream carries head refs from every contributor's fork and a head ref matches on branch name alone, so a stranger's same-named branch would otherwise be recorded as this task's PR. - 3. Folds case in every repo-slug comparison, since a GitHub slug is unique only up to casing. `gh` reports GitHub's canonical casing and the clone's `origin` remote carries whatever the operator typed; an `upstream` naming the project's own repo in another casing is that same repo and is not searched twice. - 4. Resolves a branch carrying several PRs by preference tier - merged, then open, then closed-unmerged - and only when the winning tier holds exactly one PR. More than one match in a tier is ambiguous, and so is a merged PR coexisting with an open one on the same head ref: an open PR is live evidence the branch may carry unlanded work. - 5. Puts matches from both searched repos through that one tier pass, so a fork whose upstream also carries a PR on that branch name is ambiguous exactly like two PRs in one repo, and names each candidate with its repo (`owner/repo#N`) rather than a bare number. - 6. Skips a `scout` task, whose deliverable is `data//report.md` and never a PR. - - The whole lookup is best-effort and non-blocking: a held task lock, an unreachable `gh`, an ambiguous branch, or a task with no branch each leave the command reporting what it read. A fleet-wide `hand status` never pays this cost. -3. Query herdr for current agent state and recent output. -4. Read the task's report channel (see "Report channel") and show its last 5 lines. The read covers the whole file even though only 5 lines are shown, since the unacknowledged check below is answered from all of it, exactly as the fleet overview answers it - a window would let trailing free text hide from one view a completion the other flags. A report file that exists but can't be read degrades exactly as it does in the fleet overview: the `report` field reads `report unreadable: ` and the rest of the detail view still prints, rather than the command failing and showing nothing. A terminal report no watcher has consumed appends ` (unacknowledged)` to that field; see "Unacknowledged terminal reports" under "Report channel". -5. Read the hold on this id, if any (see "Holds" under "State management"). Unlike the report channel, a failure to read it fails the command - the same reasoning as the fleet overview's `holds[N]` block. -6. If the task is a `ship` task reported `done` with a recorded PR: look up its project (unlike the fleet overview's best-effort registry read, a failure here fails the command - this id's own project is the one fact the check is about) and, if it is a registered `no-mistakes` project, check whether that PR ever went through a gate run (see "Gate-run visibility" below). -7. Emit the selected fields, then `report_history[N]`, then `help[N]`. - -Output (single task): -``` -id: fix-login -project: nsr -kind: ship -harness: claude -model: sonnet -state: working -worktree: /home/user/.treehouse/nsr-abc/1/nsr -herdr: "default/wA:tB" -age: 2h ago -last_report: 3m ago -pr: none -reported: needs-decision -report: "needs-decision: two ways to fix the race, ask-user found both risky" -delivered: none -held: "waiting on migrate-schema: needs the new column before this can proceed" -gate: none -flags: none -report_file: /home/user/secondhand/state/fix-login.status -report_history[1]: - - working: added the retry loop -help[1]: - - Run `hand send fix-login ` to answer this worker -``` - -This view defaults to every field the fleet overview leaves out; `--fields` narrows it the same way, drawing from the same vocabulary. -Every default field is emitted every time, `none` when it has nothing behind it, so the shape a caller parses never changes with the content: `pr` reads `none` with no PR recorded, `held` only carries a detail when this id has a hold, and `gate` only when the gate-run check came back `no run found` or `unreachable`. -The merge state, the gate issue and the unacknowledged marker live in `flags` here exactly as they do in the fleet overview, never as suffixes on the fields they qualify. - -`held` reads the reason alone for an `operator` or `limit` hold, `waiting on : ` for a `blocked` one, and `inconsistent: ` for a row that can't be trusted at face value (see the fleet overview above). A `limit` hold's reason is written by `hand watch` and says which resume attempt it is on and when it next tries (see "Resuming a usage-limited worker"). -`report`, and every entry in `report_history`, is the worker's own last claim about itself and not something `hand` has verified - the same caution as the `done`-vs-`reported-done` distinction in `hand watch`. - -Without `--full`: -- The `report` field and every history entry are capped to 200 runes, a character budget rather than a word or line count. The cut lands after the state-vocabulary prefix (`working:`, `paused:`, `blocked:`, `needs-decision:`, `done:`, `failed:`) - the prefix is never part of what's cut - and a cut entry carries the recovery hint every truncated field carries (see "Output shape"), naming `hand status --full` as what returns the whole text. -- `report_history` drops the entry the `report` field already carries, so the same report is never emitted twice in one invocation. That is whichever entry the field actually rendered, not simply the last one: an unacknowledged terminal report followed by free text renders the terminal line, so the free text is what stays in the history block, and a terminal report the free text pushed out of the 5-line window drops nothing. -- The `report_file` field names the absolute path to `state/.status`. It is emitted whether or not anything was cut. -- The truncation adds a `help[]` line naming `hand status --full`. - -`--full` restores the pre-atqamz/secondhand#65 content: `report` and `report_history` untruncated, and the latest entry repeated in history. - -`--json` is never truncated or deduped, `--full` or not - see the JSON section below. - -Why: `docs/adr/the-report-channel-is-the-only-outcome-signal.md`, `docs/adr/an-unrecorded-pr-is-recovered-by-head-ref.md`, `docs/adr/believe-the-status-file-and-ship-no-hand-dump.md`. - -Output (JSON, single task): -```json -{ - "id": "fix-login", - "project": "nsr", - "kind": "ship", - "harness": "claude", - "agent_state": "working", - "worktree": "/home/user/.treehouse/nsr-abc/1/nsr", - "herdr": {"session": "default", "tab_id": "wA:tB", "pane_id": "wA:pC"}, - "pr": "", - "merged": false, - "pr_merged_observed": false, - "created_at": "2026-07-24T08:00:00Z", - "last_report_at": "2026-07-24T09:57:00Z", - "reported": {"state": "needs-decision", "note": "two ways to fix the race, ask-user found both risky"}, - "report_history": ["working: added the retry loop", "needs-decision: two ways to fix the race, ask-user found both risky"], - "held": {"id": "fix-login", "kind": "blocked", "reason": "needs the new column before this can proceed", "blocked_on": "migrate-schema", "set_at": "2026-07-24T09:00:00Z"} -} -``` - -`reported` and `report_history` are omitted when the task has no report file yet, and so is `last_report_at`. `unacknowledged` is omitted unless it is true (see "Unacknowledged terminal reports" under "Report channel"). `held` is omitted when this id has no hold; an inconsistent hold (see the fleet overview above) adds an `inconsistent` field naming why instead of being omitted. `gate_run_issue` carries the same `no run found` / `unreachable` text as the `gate` field above and is omitted whenever that field would read `none` - not a `done` `ship` task with a recorded PR on a registered `no-mistakes` project, or the check came back clean - which is why the example above, with no PR recorded, does not carry it. - -Fleet-overview JSON wraps the per-task rows rather than returning a bare array, so holds - which can outlive the task that had them - have somewhere to sit alongside it, and `task_count` alongside that, always present (never omitted, zero included) so an empty fleet is a positive statement rather than the same absence of output a broken command could also produce: - -```json -{ - "task_count": 1, - "tasks": [{"id": "fix-login", "...": "one row per task, reported only, no history"}], - "holds": [{"id": "fix-login", "kind": "blocked", "reason": "needs the new column before this can proceed", "blocked_on": "migrate-schema", "set_at": "2026-07-24T09:00:00Z"}] -} -``` - -An empty fleet returns `{"task_count": 0, "tasks": [], "holds": [...]}` - `holds` still carries any -open hold, never suppressed by there being no tasks. - -Each row in `tasks` carries `gate_run_issue` under the same omission rule as the single-task JSON above. - -Errors: -- Task ID not found. -- Herdr unreachable (graceful degradation: show state as "unknown"). -- The hold store can't be read (fails the command; never degrades to an empty `holds[N]` block - see "Holds" under "State management"). -- The single-task view's own project can't be read while checking gate-run visibility (fails the command; the fleet overview's equivalent lookup is best-effort instead - see behavior step 6 above). -- `--fields` names a field no view carries, or is combined with `--json` (usage error, exit 2 - see "Output shape"). - ---- - -### `hand send [message]` - -Send a text message to a running worker's herdr pane. - -``` -hand send fix-login "focus on the auth middleware, not the test framework" -hand send fix-login --file data/fix-login/steer.md -``` - -A busy composer (agent mid-response) is the normal state a steer arrives into, not an error -condition: `hand send` waits for it to free, bounded by `--wait`, and the elapsed bound is its own -exit code rather than a hang. - -Flags: -- `--file `: read the message from this file instead of the positional argument, trailing - newlines trimmed. Mutually exclusive with the positional `message` - exactly one of the two is - required. -- `--wait `: how long to wait for a busy composer to free before giving up. Default - `config/send-wait`, or `2m` if that is unset too. - -Behavior: -1. Read the task's row for herdr pane coordinates. -2. Check herdr pane exists and agent is present. -3. Acquire a per-task send lock (`send:`), held for the rest of this list, so a second - `hand send` against the same task waits behind the first rather than polling the same pane at - the same time. `hand watch`'s usage-limit resume attempt is the only other holder of this lock, - and it takes it without waiting (see "Resuming a usage-limited worker" under `hand watch`). -4. If the composer is busy, poll until it frees or `--wait` elapses. -5. Whenever the message does not demonstrably land in the pane - `--wait` elapses first, the text - fails to send, or the submit keystroke fails after the text went in - durably record the message - and a timestamp on the task row (`send_undelivered_message`, `send_undelivered_at`) under a - separate, short-lived task-row lock, never held for the wait itself. Only the elapsed-`--wait` - case exits distinctly (see "Exit codes"); the two delivery failures are ordinary exit-1 errors. -6. Otherwise, submit the message text and clear any previously recorded undelivered-send trace on - the task row, whatever message that trace carries. A failure to clear the trace warns on stderr - and still succeeds, since the message is already in the pane. - -Output: -``` -id: fix-login -result: sent -chars: 42 -help[1]: - - The pane has the message; run `hand status fix-login` to read what it does with it -``` - -`chars` counts runes, not bytes, over the message as `hand` read it, `--file` or argument. - -Errors: -- Task not found (exit 3). -- Neither or both of the positional `message` and `--file` given (exit 2). -- Invalid `--wait` value (exit 2 from the flag, exit 1 from a bad `config/send-wait`). -- Herdr pane doesn't exist (agent died, tab closed) - exit 1, distinct from a busy composer because - this send can never succeed, no matter how long it waits. This covers a pane that stops answering - partway through the wait too, not only one already gone when the wait starts. -- Sending or submitting the message text failed - exit 1. The message is recorded as undelivered - (step 5): text that never left, and text left unsubmitted in the composer, are both a steer with - no evidence it landed. -- Composer still busy after `--wait` elapses - exit 6, distinct from the pane-not-found case above - because a caller can retry it with a longer `--wait`. The message is durably recorded as - undelivered; see step 5 above. - -Why: `docs/adr/a-steer-that-never-lands-leaves-a-durable-trace.md`. - ---- - -### `hand hold set --kind --reason [--blocked-on ]` - -Record that an id is waiting on something. See "Holds" under "State management" for the design this command exposes. - -``` -hand hold set fix-login --kind operator --reason "two ways to fix this, needs a call" -hand hold set fix-login --kind blocked --reason "waiting on the migration task" --blocked-on migrate-schema -``` - -`` is any id, not only a live task's - see "Holds" for why. It is validated with the same charset as a task id (`state.ValidateID`), never read back against the task table. - -Flags: -- `--kind`: `operator` (waiting on a human) or `blocked` (waiting on another id). Required. `limit` is a valid hold kind but not a valid value here - it is `hand watch`'s (see "Holds"). -- `--reason`: why the id is waiting. Required. -- `--blocked-on`: the id being waited on. Required for `--kind blocked`, refused for `--kind operator`. - -Behavior: -1. Validate `--kind`, `--reason`, and the `--blocked-on` pairing. -2. Upsert the hold row - a second `hand hold set` on the same id replaces the previous kind, reason, and blocked-on rather than requiring a clear first, and refreshes `set_at` to when it was last set. - -Output: -``` -id: fix-login -result: held -kind: operator -reason: two ways to do this, needs a call -blocked_on: none -help[1]: - - `hand status` carries this in its holds block until `hand hold clear fix-login` -``` - -Errors: -- Invalid `--kind` (exit 2). -- `--kind limit` (exit 2). Refused with its own message rather than the generic one: the kind exists, so the error says who sets it and when it clears instead of claiming it is not a kind at all. -- Missing `--reason` (exit 2). -- `--blocked-on` missing for a `blocked` hold, or given for an `operator` hold (exit 2). - ---- - -### `hand hold clear ` - -Clear the hold on an id. - -``` -hand hold clear fix-login -``` - -Behavior: -1. Delete the hold row. Leaves no residue - a subsequent `hand status` shows nothing held for this id. - -Every kind is clearable, `limit` included: it is the operator's way out of a hold `hand watch` set on their behalf (see "Holds"). Clearing one does not cancel the resume schedule behind it, which lives on the task row - the next `hand watch` tick that attempts or ends the limit writes the hold back or leaves it gone accordingly. - -Output: -``` -id: fix-login -result: released -help[1]: - - Run `hand status fix-login` for where that task stands now that nothing holds it -``` - -Errors: -- No hold set on `` (exit 3). - ---- - -### `hand deliver --reason ` - -Record that a task's work is handed off and the decision to land it belongs to someone outside the fleet. - -``` -hand deliver no-mistakes-flake --reason "PR https://github.com/kunchenguid/no-mistakes/pull/597 offered upstream, maintainer decides" -hand deliver event-delivery-scout --reason "report at /home/atqa/secondhand/data/event-delivery-scout/report.md, no code to land" -``` - -This is a terminal state `hand teardown`'s landed-work guard cannot express: for a contribution offered to a repo the fleet does not control, landing is someone else's decision and possibly never taken. - -Behavior: -1. Refuse without `--reason`: the record has to say what was delivered and who decides whether it lands, not merely that something was. -2. Write `delivered_at` (now, UTC) and `delivered_reason` onto the task's row, under the task lock. - -Re-running with a new reason is a correction rather than a conflict, unlike `hand pr`'s one-task-one-PR rule. - -The state is keyed off the recorded delivery, never off `kind`, so a task filed as a ship whose deliverable turned out to be a report tears down cleanly without anyone correcting the kind first. - -`hand status` shows it: a `delivered` token in the fleet view's `flags` column, a `delivered` field carrying the reason in the single-task view, and `delivered_at`/`delivered_reason` in `--json`. It never sets `merged` or `pr_merged_observed`, which both assert the work landed. - -Why: `docs/adr/deliver-names-the-state-instead-of-widening-force.md`. - -Output: -``` -id: no-mistakes-flake -result: delivered -reason: "PR 597 offered upstream, maintainer decides" -delivered: "2026-07-29T11:04:00Z" -help[1]: - - Run `hand teardown no-mistakes-flake` once the work is landed to release the worktree and pane -``` - -Errors: -- `--reason` missing (usage error, code `2`). -- Task not found. - ---- - -### `hand teardown [flags]` - -Clean up a completed task. Fail-closed: refuses if work isn't properly landed. - -``` -hand teardown fix-login -hand teardown investigate --force -``` - -Flags: -- `--force`: skip landed-work checks (requires explicit authorization). - -Behavior (ship task): -1. Check the worktree for uncommitted changes. A dirty worktree is not an automatic refusal: teardown proceeds past it when every uncommitted change is a tracked modification whose current content already matches the local default branch's tip byte for byte. - - The comparison is content-identical, not path-identical: a same-named file with different content, or a path that merely exists in the base, both still refuse. - - Both layers a `git status --porcelain` line reports are compared, index and working tree, each where it reports a change. An `MM` path whose working copy matches the base still holds a third, differing version staged. - - Untracked files are never safe, so their presence refuses regardless of what else is safe. - - Every failure to resolve, read or parse fails closed into the refusal, and resolution is local-only with no fetch. - - A refusal carries the worktree's `git status --porcelain` output, capped at the first 20 entries plus a count of the rest. -2. If the task is recorded as delivered (`hand deliver`), stop here and treat the work as terminal. Deliberately after step 1 and after the scout task's own report check, so `--force` keeps its one meaning of discarding work nobody delivered: uncommitted changes still refuse, and a scout row claiming delivery with no report on disk has delivered nothing. -3. Check work is landed: - - If mode is `local-only`: verify the branch is merged into the default branch. - - Otherwise, if `pr` is not yet set in state and the project is registered: run the same head-ref PR detection `hand status` performs, including its preference-tier rule, and record what it finds. Detection finding nothing (no clone on disk, `gh` unreachable) falls through to the refusal below as if no PR existed. An ambiguous branch refuses outright instead, and the refusal names every PR on the head ref with its repo and state, including ones in losing tiers, since the operator has to resolve the whole branch. - - If `pr` is set in state (recorded by `hand pr`, or just detected above): verify the PR is merged via `gh pr view`. A detected PR that is closed without merging is refused exactly like one `hand pr` recorded. - - If no PR is found and the row carries no merge evidence (`merged`, `pr_merged_observed`), but `data//report.md` exists and the worktree's branch adds no commit to the local default branch, treat the task as a completed scout: stop here and record the completion as `kind: scout`, outcome `done`. Otherwise refuse: work may not be landed. Both halves of the condition are required, the branch comparison fails closed exactly like step 1's, and this case is decided last so it can only answer what nothing above it claims. -4. Close the herdr tab. -5. Return the worktree to treehouse: `treehouse return `. `--force` is added whenever step 1 proceeded past dirt it judged safe, as well as under the command's own `--force`, since treehouse refuses to clean a dirty worktree without it and there is nothing here to answer its prompt. - That abort is the one treehouse failure its exit status does not report - it prints the abort and still exits 0 with the slot leased - so the return is judged on the output and never taken for a returned worktree. - A scout is the path that reaches it: its checks read the report on disk and never the worktree, so dirt is still in place when the return runs. -6. Append a completion record to `state/completions.jsonl` (see "Completion store" below). -7. Remove the task's row and the task's report channel `state/.status`. -8. Keep `data//brief.md` for history (the agent can prune old briefs). - -The report channel goes because it is the volatile wake log, not a deliverable: a task respawned under a used ID starts at `report_offset` 0, so a surviving log would be replayed as this run's. The durable deliverables under `data//` survive teardown. -A hold on the id is not removed, deliberately - it is not task-scoped, so it outlives the row, which is also why `hand spawn` then refuses the id until `hand hold clear` (see "Holds" under "State management"). One kind is the exception: a `limit` hold is released, since nothing is left to resume and no watcher will ever clear it. Only that kind, and a failure to release it is a warning rather than an error - the delete has already happened and re-running teardown cannot undo it. - -Behavior (scout task): -1. Check `data//report.md` exists (the report is the deliverable). -2. Close the herdr tab. -3. Return the worktree to treehouse. -4. Append a completion record to `state/completions.jsonl`. -5. Remove the task's row and `state/.status`. - -Behavior with `--force`: -- Skip steps 1-3 for ship tasks, skip step 1 for scout tasks. -- Still closes herdr tab and returns worktree. -- Stays the escape hatch for genuinely unlanded work only. Work that is delivered and not landed has its own state (`hand deliver`). - -Teardown removes several resources in sequence, and any step can fault, so the command is runnable a second time: a resource already released is that step's goal already reached, not an error, and never something `--force` is needed for. -A tab herdr no longer lists counts as closed. -A worktree already back in its pool counts as returned, on treehouse's own answer rather than inferred from the path being gone - a returned worktree keeps its pool slot directory, so nothing can tell it from a leased one by looking. -The report channel is removed before the task's row, since the report removal is the one that can fail on an I/O fault and doing it first leaves the row, and with it the retry, intact. - -#### Completion store - -The completion record is appended before the task's row is removed, not after, because the record is derived from the task state that removal would take out from under it. The two sides of that ordering fail in deliberately different directions: - -- If the append itself fails, the command returns before the task's row is touched, so the whole command is retryable. -- If the state removal that follows it fails, the record already written is durable and not thereby wrong. The task's row is left in place, so a retry replays the whole command and appends a second, functionally duplicate record. - -`state/completions.jsonl` is a sibling of `state/events.log` rather than a share of it: it takes a dedicated lock and performs one `O_APPEND` write per record, where `events.log`'s writer reads, appends and rewrites via a temp-file rename. - -The store is uncapped. Each line is a complete JSON object (`id`, `project`, `kind`, `outcome`, `detail`, `torndown_at`), readable without parsing markdown. - -`outcome` is one of: -- `merged`: the work landed. `detail` names the PR, or `branch merged` for a local-only branch. -- `done`: a scout task's report is the deliverable. `detail` names the report path. -- `delivered`: the work is handed off and its landing was never the fleet's to decide. `detail` is the reason `hand deliver` recorded, prefixed with the PR when one is on the task. Ranked ahead of every outcome above, all of which assert the work landed, but only while the task's row carries no merge: a delivery the upstream maintainer then actually merged (`merged` or `pr_merged_observed` on the row) records `merged` instead. -- `torn-down`: `--force` skipped the checks, so nothing about landing is claimed at all. - -Output: -``` -id: fix-login -result: torn-down -project: nsr -kind: ship -outcome: merged -detail: "https://github.com/org/repo/pull/42" -worktree: returned -help[1]: - - This id is gone from `hand status`; its completion is the last word on it -``` - -`outcome` and `detail` are the completion record's own fields, so what teardown says and what the -permanent record holds cannot drift. - -Errors: -- Task not found. -- No PR recorded and the project is not local-only (without `--force`, and unless the task is recorded as delivered, or its work is a completed scout deliverable - `data//report.md` on disk, a branch adding no commit to the local default branch, and no merge on the row). -- Uncommitted changes in worktree, unless every change is content-identical to the local default branch's tip in both the index and the working tree (without `--force`); the error carries a capped `git status --porcelain` of the worktree. -- PR not merged (without `--force`). -- Ambiguous PR head ref: the task's branch carries several PRs that do not resolve to a single usable winner - no preference tier holds exactly one match, or a merged PR coexists with an open one (without `--force`). -- Report not found for scout task (without `--force`). -- Treehouse return failed (worktree locked, path no pool manages), or an unforced return aborted on a dirty worktree and left the slot leased. The task's row is kept either way, since it is the only record of the leased slot. -- Herdr tab close failed (graceful: warn and continue). -- Completion record append failed (lock or I/O fault): task state is left untouched, so a retry is safe. - -Why: `docs/adr/the-landed-work-guard-reads-the-work-not-the-record.md`, `docs/adr/the-completion-store-is-an-uncapped-append-only-sibling.md`, `docs/adr/deliver-names-the-state-instead-of-widening-force.md`. - ---- - -### `hand merge [flags]` - -Merge a task's completed work. - -``` -hand merge fix-login -hand merge fix-login --squash -hand merge local-task --local -``` - -Flags: -- `--squash`: squash merge (default for PR merges). -- `--merge`: merge commit instead of squash. -- `--rebase`: rebase merge. -- `--local`: fast-forward merge for local-only tasks (merges task branch into default branch in the project clone). - -Behavior (PR merge, default): -1. Read the task's row for the PR URL. -2. Refuse if no PR is recorded. -3. Refuse if the PR is already merged (a no-mistakes gate, or `hand teardown`/`hand status`'s own gate-opened-PR detection, can record a PR `hand merge` never merged itself). -4. Check PR CI status via `gh pr checks`. -5. Refuse if checks are not green. -6. Run `gh pr merge --repo --squash` (or specified method). -7. Update the task's row with merge status. -8. Run `hand project sync ` to fast-forward the project clone. - -Behavior (local merge, `--local`): -1. Read the task's row for worktree and project. -2. Refuse if worktree has uncommitted changes. -3. Determine the task branch from the worktree. -4. In the project clone: `git merge --ff-only `. -5. Refuse if fast-forward is not possible (diverged branches). -6. Update the task's row with merge status. - -Output: -``` -id: fix-login -result: merged -method: squash -pr: "https://github.com/org/repo/pull/42" -merged: "2026-07-29T11:04:00Z" -help[1]: - - Run `hand teardown fix-login` to release this task's worktree and pane -``` - -Output (local): -``` -id: fix-login -result: merged -method: local-fast-forward -branch: 42-fix-login -into: main -merged: "2026-07-29T11:04:00Z" -help[1]: - - Run `hand teardown fix-login` to release this task's worktree and pane -``` - -Errors: -- Task not found. -- No PR recorded in task state (for PR merge). -- PR checks not green. -- PR already merged. -- PR has merge conflicts. -- gh merge fails. -- Fast-forward not possible (for local merge). -- Uncommitted changes in worktree (for local merge). - ---- - -### `hand pr ` - -Record a task's pull request URL. The normal path to a recorded PR is automatic: `hand watch` records it as soon as a single PR URL appears on the task's report channel (see "Report channel"). `hand pr` exists for the worker or supervisory agent to record it explicitly - before the report channel catches it, or when the worker's harness has no way to reach the report channel. - -``` -hand pr fix-login https://github.com/org/repo/pull/42 -``` - -Behavior: -1. Validate `` matches `https://github.com///pull/` exactly - anchored, no substring matching. -2. Read the task's row. -3. If the task already has this exact PR recorded, skip steps 5-7 and report success without writing anything again. This reconciling repeat is what makes `hand pr ` a sound remedy for a `pr-record-unknown` event. -4. If the task already has a *different* PR recorded, refuse: one task, one PR, and correcting a wrong record is a deliberate `hand teardown`/`hand spawn` decision. -5. Resolve the task's project and derive `owner/repo` from the project clone's own `origin` remote, read with `git config --get remote.origin.url` rather than `git remote get-url`, so a local `url..insteadOf` rewrite cannot turn a mismatch into a match. -6. Refuse if the URL's `owner/repo` matches neither the derived repo slug nor the project's declared `upstream` (see "Project registry format"). The upstream passes only because an operator declared it with `hand project upstream`, never because the URL's repo looks related to the project's own. The refusal names the declared upstream, or says none is declared. Both comparisons fold case. -7. Confirm the PR exists via `gh pr view` (network check, 30s timeout). The refusal names the repo the *URL* belongs to, not the project's own, since step 6 accepts a PR on the declared upstream too. -8. Write `pr` into the task's row. - -Steps 5-7 live in `project.ValidatePR` and are the *only* validation path: `hand watch`'s auto-record calls the same function, so a worker-supplied URL can never reach task state on weaker terms than an explicit `hand pr`. - -Output: -``` -id: fix-login -result: recorded -pr: "https://github.com/org/repo/pull/42" -help[1]: - - Run `hand merge fix-login` once this PR's checks are green -``` - -Output (reconciling repeat) - same document, `result: already-recorded`. - -Errors: -- Malformed PR URL (usage error, code `2`). -- Task not found. -- Task already has a different PR recorded. -- Project not registered. -- Cannot derive `owner/repo` from the project clone's origin remote. -- URL's repo matches neither the project's repo nor its declared upstream. -- PR not found via `gh pr view` (network error or nonexistent PR). - -Why: `docs/adr/an-unrecorded-pr-is-recovered-by-head-ref.md`. - ---- - -### `hand watch [flags]` - -Blocking watcher. Polls herdr agent states and prints actionable events to stdout. -Also logs events to `state/events.log` for crash recovery. -One fleet home has at most one watcher at a time (see "One watcher per fleet home"). - -``` -hand watch -hand watch --poll 10s -hand watch --until-event --timeout 30m -hand watch --until-event --event parked,failed -hand watch --takeover -``` - -Flags: -- `--poll `: poll interval when push events aren't available. Default: value from `config/watch-interval`, or `5s`. -- `--until-event`: block until the first events, print them, exit `0`. See "Delivering an event to a supervisory agent" below. -- `--timeout `: with `--until-event`, give up after this long and exit `4`. Default: no timeout. Without `--until-event` it is a usage error (exit `2`), since a streaming watcher has no completion to bound. -- `--event `: with `--until-event`, wake only on the given event kinds; repeatable or comma-separated. Default: any. Without `--until-event` it is a usage error (exit `2`), since the streaming path has no wake to filter - it prints every actionable event regardless. An unrecognized kind is also a usage error, naming the full known set. This is a stdout-only filter: `state/events.log` still receives every actionable event, filtered or not. Kinds are internal identifiers, not the printed line's leading word - most agree (`stale`, `parked`, `blocked`, `failed`), but the report-derived ones don't: a `working : ` line filters on `report-working`, a `reported-done`/`done : ` line on `report-done`, and so on for every entry in "Report channel" below. -- `--takeover`: replace the watcher already attached to this fleet home instead of refusing, signaling it to stop first. A no-op when nothing is attached, so it is safe to pass unconditionally. See "One watcher per fleet home". - -Behavior: -1. List all active tasks from the store. -2. Subscribe to herdr's `agent_status_changed` push events if available. -3. Fall back to polling herdr agent states at `--poll` interval. -4. Classify each state change: - - `idle-unreported `: agent stopped being busy after working/blocked - herdr reports this as `idle` or `done` interchangeably (see "Agent state" below; hand's polling model observes `done`, essentially always, never `idle`) - but its report channel (see "Report channel") doesn't explain the stop: no report at all, or the last line was still `working`. Any other terminal report (`paused`, `blocked`, `needs-decision`, `done`, `failed`) already explains the stop, so that transition is absorbed silently instead. - - `blocked : `: agent reports blocked (herdr-level; herdr gives no free-text reason, so `` is a fixed string). - - `failed `: herdr pane died unexpectedly. For a task already tracked, this fires the moment a probe that used to succeed starts failing, with no dwell. A task first sighted with its very first probe already failing - a re-scan picking up a fresh spawn between arm and its first successful probe - gets a dwell first, measured against `config/stale-threshold`, so a pane that blinks while herdr catches up to a just-spawned worker produces no event. Either path fires once per outage and stays quiet until the pane is next seen healthy. - - `stale `: agent hasn't changed state for longer than the stale threshold (default 300s, configurable via `config/stale-threshold`). - - `parked : (silent )`: the report channel has stopped growing for longer than its bound, independently of `stale`, which watches herdr transitions only. The bound is chosen by the last classified report line: `paused` gets `config/parked-paused-bound` (default 3600s), `done` and `failed` get `config/parked-done-bound` (default 5400s), and everything else - `working`, `blocked`, `needs-decision`, or no report at all - gets `config/parked-other-bound` (default 1200s). `done` and `failed` are bounded rather than exempt. Edge-triggered like every other trigger: it fires once per silence episode and refires only once the report file grows past the mtime it fired for, which is persisted as `parked_fired_for` rather than re-derived. The event carries the last line and its age and leaves the process check to the caller (`hand status `, or the session directly). - - `usage-limit : harness stopped on a usage limit; made, next try `: the worker's harness stopped because its account ran out of quota, and a resume is now scheduled (see "Resuming a usage-limited worker" below). `` counts the resume attempts spent on this limit so far, reading `no attempts` on the first line, then `1 attempt`, then ` attempts`. Also recorded as a hold of kind `limit` carrying that same text as its reason, so the wait is visible in `hand status` and `hand spawn` will not hand the id to new work while a worker still sits on it. - - `usage-limit-resumed : running again after `: a task that was waiting on quota is running again, however that happened - a resume attempt that landed, an operator `hand send`, a human typing in the pane. The `limit` hold is released with it. - - `usage-limit-stuck : `: the resume mechanism has spent six attempts on one limit and is out of its own answers. Fired once per limit, and attempts continue afterwards - a week-long limit is real and does eventually lift - but no longer quietly. This is the only one of the three that notifies (see "Notifying a supervisory agent with no session watching"). - - `pr-merged `: a recorded PR has been merged (checked periodically via `gh pr view`). Announced once ever: the observation is recorded as `pr_merged_observed` on the task after the line is printed, so a restart neither repeats it nor loses it to a crash between the two. The same marker is set outside the watcher when gate-opened-PR detection records a PR that is already merged (see `hand status`), so a watcher that first sees the task after that stays quiet about a merge it never observed. - - `pr-not-recorded : ()`: a PR URL a worker embedded in a report line was attempted and the recording did not complete. The token says only that much, for any cause - refused validation, an unregistered or unresolvable project, an unreadable task record, a failed state write - and `` carries the underlying error. The whole cause is kept; only its line breaks are not, since an event is one line on stdout and one entry in `state/events.log`. The fix in every case is a human running `hand pr `, which either records the URL or fails with the real underlying reason. The kind is never split by cause. - - `pr-record-unknown : ()`: the same URL was never attempted, because another command held the task lock at that moment. Whether it ended up recorded is genuinely unknown - the holder may be the `hand pr` recording that very URL - so this event asserts nothing about the outcome and points at `hand status ` to confirm, except when the task's own state can't be read, where it names that read failure instead of a remedy that would hit it too. Nothing is announced at all when the lock holder is found to have already recorded that same URL. - - Both auto-record events are durable on stdout and in `state/events.log` (plus a stderr diagnostic) rather than only a transient stderr line, since the report line is consumed either way. - - `working : ` / `paused : ` / `report-blocked : ` / `needs-decision : ` / `report-failed : `: a new line landed on the task's report channel, classified per "Report channel" above. - - `reported-done : ` / `done : `: a `done` report line landed; printed as `reported-done` until cross-checked against the task kind's completion evidence (a merged PR for ship, `data//report.md` for scout), then once as `done` when that evidence lands - which is usually a later tick (see "Report channel"). - - `malformed report : `: a report line didn't match the fixed vocabulary. Surfaced, never dropped, so a typo in the worker's report doesn't silently vanish. - - Benign events (working herdr transitions, routine transitions): absorbed silently. -5. Print one line to stdout per actionable event. -6. Append each actionable event to `state/events.log` (bounded: keep last 200 lines, rotate on overflow). -7. Re-scan `state/` periodically to pick up newly spawned or torn-down tasks. -8. Exit cleanly on SIGINT/SIGTERM. -9. While tailing a task's report channel, a line carrying exactly one PR URL auto-records it if the task doesn't already have one, subject to the same validation `hand pr` enforces; a URL whose recording was attempted and did not complete is surfaced as `pr-not-recorded`, and one the watcher never got to attempt as `pr-record-unknown` (see "Report channel"). -10. Every task-state write the poll loop makes - the bookkeeping it owns (`report_offset`, `pr_merged_observed`) and an auto-recorded PR - takes the task lock non-blocking, and is skipped when another command holds it. The poll loop never waits on the **task** lock, which is held across unbounded network and git work elsewhere. Bookkeeping is re-derivable and simply retries next tick. A skipped auto-record is announced as `pr-record-unknown` - never as `pr-not-recorded`, which covers every attempt that was made and did not complete - except when the lock holder turns out to have recorded that same URL, which is silent. -11. Per-task bookkeeping is written back only after the tick's events are announced, never before. - -#### One watcher per fleet home - -`hand watch` acquires ownership of the fleet home before it polls anything, and refuses with exit `3` when another watcher already holds it, naming the incumbent's pid and `--takeover` as the remedy. - -**Ownership is an `flock` on `state/watch.pid`, never the pid the file contains.** -The kernel releases an `flock` when its holder dies, however it died, so a crashed watcher leaves nothing stale behind to clear. -The pid recorded inside the lock is advisory only - it lets a refusal name the incumbent and lets `--takeover` signal it - and a read that races the incumbent's own write degrades to `unknown` rather than to some other process's pid, since the value is only trusted when it arrives newline-terminated. - -`--takeover` sends the incumbent SIGTERM, which `hand watch` already handles as a clean shutdown, and then waits up to 5s for the lock to come free. -If it does not, the takeover fails rather than proceeding: a takeover that cannot confirm the incumbent is gone would become the second watcher it exists to prevent. -Ownership is per fleet home and shared by both modes, so a streaming `hand watch` also blocks a `hand watch --until-event` arming against the same home - the arming watcher would consume report lines out from under the streaming one - and `--takeover` is how a caller that wants the window says so. - -#### Delivering an event to a supervisory agent - -`--until-event` makes the process exit the delivery, because a supervisory agent's background-task runner re-invokes the agent when a process *exits* and a streaming watcher never does: - -1. Arm: connect to herdr and probe every active task's pane once, before anything else, both bounded by `--timeout`. Any task that *fails* its probe is named on stderr and exits `5`, never entering the baseline half-armed. Losing the race against `--timeout` instead is exit `4`, not `5`, since no single task can be named as the cause. A task first seen only later, after arming - a re-scan picking up a fresh spawn - has no such gate and is handled by the live poll's own first-sighting tracking; see the `failed` bullet above and "What survives a `hand watch` restart" below. -2. Take the baseline silently: two ticks with stdout discarded. The first seeds tracking for every task, the second consumes whatever a previous watcher left unconsumed on the report channels, since `report_offset` survives a restart on purpose. This second tick is also the first to classify against durable evidence, so a task already `stale` or already `parked` before this process ever started is absorbed here exactly as an already-`done` one is (see "The startup state is never an event" below). -3. Poll. On the first tick that produces any event, write that tick's events to stdout and exit `0`. -4. On `--timeout`, whether it elapses during arming or during the poll, write nothing to stdout, name the elapsed timeout on stderr, and exit `4`. -5. On SIGINT/SIGTERM, the same as a timeout: nothing was delivered, so exit `4`, never `0`. - -Four rules govern the wait: - -- **The startup state is never an event.** Only a change from the baseline exits. -- **Every wake trigger is edge-triggered, `idle-unreported`, `stale`, and `parked` included.** A worker fires one on entering the condition and does not fire again until it leaves and re-enters, so no signal has to be excluded from the trigger to avoid a wake storm. -- **Arming itself can fail loudly, distinct from every other exit.** A worker whose pane answers with a failure at arm time is `5`, naming the worker; an arm probe that instead runs past `--timeout` names no worker and is `4`. -- **The exit code says which happened**: `0` an event was delivered, `4` no event (timeout or signal, wherever the timeout lands), `5` a named task's pane failed its arm-time probe, `3` another watcher already owns this fleet home so the arming never happened (see "One watcher per fleet home"), `1` the watcher itself failed, `2` a usage error. A caller can never read a crash or a quiet window as fleet news. - -Worst-case delay from a real transition to the exit that delivers it is one `--poll` interval (`config/watch-interval`, default 5s) plus that tick's own bounded work. -The only unbounded-looking piece, a `gh pr view` check for each task with a recorded, not-yet-confirmed-merged PR, is capped at 30s per task and run one task at a time, so a fleet with several such tasks can push a single tick past the poll interval but never past that cap times the count. -Once the event is written to stdout, the process exits immediately. - -Baseline events are withheld from stdout only; they still reach `state/events.log`, because the report lines behind them are consumed either way. -So the agent's loop is: arm the watcher, read `hand status` and `state/events.log` for current truth, then treat the next exit as the answer to "what changed since I armed". -Anything that lands between one exit and the next arming is in those same two places. -One invocation delivers one wake, and re-arming is the caller's own next step after acting on the exit. - -This covers the awake path only. -`hand notify` (see its own section) is the channel that reaches an unattended fleet, and every `hand watch` invocation - `Run` and `RunUntilEvent` alike - calls it in-process for every event matching `NotifyFilter` (see below), whether or not that event was also printed to stdout. - -#### Notifying a supervisory agent with no session watching - -The notify hook is its own filtered consumer of the same classified event stream `--event` already filters for stdout, not a severity test hardcoded into `handleEvent`: `internal/watcher.NotifyFilter` builds an `EventFilter` with its own fixed membership, and `handleEvent` checks it exactly as it checks `cfg.EventFilter`. -That membership is `blocked`, `report-blocked`, `failed`, `report-failed`, `report-needs-decision`, `report-done`, and `usage-limit-stuck`. -`report-blocked` is in the set alongside the herdr-transition `blocked` because the two are independent signals, and a worker that reports blocked and then goes idle fires no other notifiable kind: `ClassifyStatus` suppresses `idle-unreported` precisely because `LastReportState` is set. -`idle-unreported`, `stale`, `parked`, `pr-merged`, the `pr-record-*` kinds, `usage-limit` and `usage-limit-resumed` are all out of the set. - -`handleEvent` calls `internal/notify.Send` directly, never by shelling out to the `hand notify` subcommand, so the wiring reaches every caller of `hand watch` with no shell wrapper. -An unconfigured `config/notify` produces no diagnostic, the same silent fallback every other `config/` default gets; a *configured* template that fails writes to the watcher's own stderr, per "Error output". -A failed send is that diagnostic and nothing more: the poll loop carries on, and the event's own stdout line is unaffected. -A template that *hangs* is bounded by a timeout inside `internal/notify.Send` surfacing as one more such diagnostic, since the send runs inline in the poll loop. - -`failed` fired from an already-unreachable pane (`ClassifyUnreachable`) can re-fire once per `hand watch` restart for a condition that was already true before it, because its latch `UnreachableFired` is deliberately non-persisted (see "What survives a `hand watch` restart" below). -No rate limiting is added on top of that. - -#### Resuming a usage-limited worker - -**Detection is a harness capability, not another condition on the poll loop.** `internal/harness` owns a catalogue of usage-limit signatures - which wordings mean "out of quota", and how to read a reset instant out of them - and exposes `SupportsUsageLimit` and `DetectUsageLimit`. -Only `claude` is in it; every other harness declines on one map lookup, with no pane read and no steer. -Teaching `hand` about a second harness is an entry in that catalogue, not a branch in the watcher. - -**The state is a durable pair of task columns, with the `limit` hold as its operator-visible projection.** `usage_limit_retry_at` and `usage_limit_attempts` are watcher bookkeeping alongside `report_offset` and `parked_fired_for`: a non-empty retry stamp *is* what makes a task limited. -The hold (kind `limit`, see "Holds" under "State management") is what makes the wait visible in `hand status` and keeps `hand spawn` from handing the id to new work while a worker still sits on it. -The schedule is the authority: the retry path reads the columns, never the hold. - -**"The limit lifted" is an observation, never a deadline that elapsed.** An attempt is the same two-call steer `hand send` performs - a plain instruction into the composer, then Enter - and it produces either a pane that starts working, which the next tick's clear check reports as `usage-limit-resumed`, or a fresh refusal on screen, which is the observation the *next* attempt is scheduled from. -A reset instant parsed out of the refusal only decides when to start trying, and it is read from the freshest refusal on screen, since an older one still in scrollback names a reset that has already passed. - -Being the same steer, an attempt holds the same `send:` lock `hand send` holds, so the two never write one composer at once (see `hand send`). -It takes that lock without waiting, since a poll tick cannot block behind an operator's whole `--wait`; a busy lock spends no attempt, leaving the schedule due for the next tick. - -**The failure mode designed against is a retry storm against an account that is still limited**, and five things bound it: - -- The first attempt waits until the instant the harness itself named, plus a minute of skew, or ten minutes when it named none. Ten minutes is also the floor: no attempt is ever sooner. -- Later attempts back off by doubling, capped at hourly. -- Every wait is capped at 24 hours, so a misparsed or absurd prediction cannot strand the worker. -- Exactly one attempt per due window, and the schedule is durable, so a watcher restart cannot reset the clock into an immediate retry - which is the one thing that would turn a restart loop into a storm. A steer that fails keeps its schedule too: rolling the stamp back would leave the task due on every tick. -- A genuinely long limit is therefore not probed hourly for a week: each attempt re-reads the harness's own fresh refusal and reschedules from the reset it names, so the pattern settles at roughly one attempt a day. After six attempts on one limit, `usage-limit-stuck` says so once on the notify channel; attempts continue, but no longer quietly. - -**A pane is read only on an edge, never per tick.** The edges are the transition into a not-busy status, and the first sighting of an already-stopped worker, which no transition can cover. -A worker whose report channel already says `done` or `failed` is left alone entirely. -Recognition matches on the quota being *reached*, so the harness's own "approaching your limit" warning is not a stop. - -The limit is released the moment the pane is observed working or blocked again, whatever caused it - a landed resume attempt, an operator `hand send`, a human typing in the pane - and `hand hold clear` stays available as the operator's way out of a hold `hand` set on their behalf. - -#### What survives a `hand watch` restart - -**Anything the watcher announces is persisted at the moment it announces it, never re-derived on restart.** -Anything added to `TaskState` belongs in this table before it ships. - -| Fact | Treatment | -|---|---| -| How far the report file is consumed | Persisted as `report_offset` and `report_digest` together, after the tick's events are announced, and trusted or discarded as one value (see "Report channel"). | -| A merge this watcher's own `gh` poll saw | Persisted as `pr_merged_observed`, after `pr-merged ` is printed. | -| The verified `done` announcement | Persisted as `done_verified`, after `done ` is printed, since `hand merge`'s evidence can land while the watcher is down. | -| An auto-recorded PR URL | Persisted as `pr` on the task; every outcome but the silently self-resolving race is announced (`pr-not-recorded` / `pr-record-unknown`) and logged. | -| Last reported state and note | Persisted as `last_report_state` and `last_report_note`, written alongside `report_offset` on the tick that consumed the line, never re-read from `state/.status`. They select `parked`'s bound, render in `hand status`, and gate the scout's deferred-`done` bookkeeping. | -| The identity of the task being tracked | Re-read as `created_at` and compared every tick: an ID torn down and respawned is a different task, so it is re-seeded from its own state rather than inheriting the previous run's. | -| The same bookkeeping across `hand promote` | Not covered by that identity check, since promote keeps `created_at` and gives the task a new pane. See "Pane-anchored facts across `hand promote`" below. | -| Current herdr agent status, and the blocked flag derived from it | Re-derived: a live pane property with no durable answer, seeded on first sight without emitting, since transitions and not states are events. | -| Whether the last probe of the pane succeeded | Re-derived, seeded from the probe that does the sighting, so it is false until one has actually succeeded - a fresh spawn, a live `hand promote`, or a resume whose own first probe fails. It gates the once-only `failed` latch and `stale`'s detection, and a pane still unreachable at its sighting is announced by that sighting's dwell rather than on sight. | -| How long the current herdr status has been dwelt in | Persisted as `status_changed_at`, updated on every observed transition and not only the ones that raise an event, and seeded from `created_at` until the first. `stale`'s dwell is measured against it. | -| Which status that dwell clock describes | Persisted alongside it as `status_changed_for`; the timestamp is trusted only while the two agree, and a mismatch starts the dwell now. | -| The stale timer's fired latch, as opposed to its dwell | Re-derived: cleared on every observed transition and unfired on resume, so a restart costs at most one duplicate `stale ` and never suppresses a re-announcement. A live `hand promote` clears it explicitly. | -| Which silence episode `parked` already fired for | Persisted as `parked_fired_for`, written after the `parked` line is announced. Stored to nanosecond precision, because it is compared to a report file's mtime for exact equality. An unparseable stamp resumes unfired. | -| That a task is waiting on quota, and when it may next be poked | Persisted as `usage_limit_retry_at` and `usage_limit_attempts`, written after `usage-limit ` is announced (see "Resuming a usage-limited worker"). An unparseable stamp resumes *unlimited*, the one place this table prefers silence to a duplicate, since the duplicate is a steer into a live pane. | -| That this watcher has already looked for a limit on this task | Re-derived: `LimitProbed` starts false on resume, which is what makes a watcher coming up against an already-stranded worker read its pane once and find the limit. | -| The first-sighting outage's fired latch, as opposed to its dwell | Re-derived, unfired on resume. Its dwell is not: a task first sighted unreachable is seeded through the same `status_changed_at`/`status_changed_for` path as any other status, with `herdr.StatusUnknown` standing in for "no real status yet". | - -#### Pane-anchored facts across `hand promote` - -`hand promote` keeps the task's `id` and `created_at` but hands it a **new herdr pane**. -The governing question for each cached fact is therefore not "is it durable" but "was it anchored to the pane". -Both halves are handled: promote clears the durable fields itself, since no watcher may be running, and `forgetPaneScopedCache` drops a live watcher's in-memory copies, which pass the `created_at` identity check untouched and would otherwise be written back onto the rewritten row on the next tick. - -Pane-anchored, and reset: - -| Fact | Reset to | -|---|---| -| `done_verified` | False. The marker belongs to the scout's verified `done`, and the write-back only ORs it to true, so a carried one would leave the ship unable to announce its own. | -| `status_changed_at` / `status_changed_for` | The promotion instant, with the status cleared, so the ship's first observed status is a fresh dwell rather than one already grown past `stale`'s threshold. | -| `pane_started_at` | The same instant, but as a separate fact: nothing that merely observes the pane ever writes it, which is what keeps `parked`'s silence floor free of the outage restamps `status_changed_at` carries. | -| `last_report_state` / `last_report_note` | Empty. A carried one both mis-bounds the ship's silence and can hand it a `done` it never reported. | -| `delivered_at` / `delivered_reason` | Empty. Carried, they would let `hand teardown` accept the ship as terminal with no PR and no merge check - the landed-work guard bypassed without `--force`. | -| The `stale` and `blocked` fired latches | Unfired. A surviving latch silences that announcement for the ship's own pane. | -| Whether the last probe of the pane succeeded | False, matching the seed a fresh spawn gets, so the ship's first probe of its new pane is a first sighting and an unreachable one is announced through that sighting's dwell rather than firing `failed` on sight. | -| The first-sighting outage's fired latch | Unfired, so a fresh pane gets a fresh episode by rule rather than by the accident of the next probe failure resetting it. | -| The usage-limit schedule (`usage_limit_retry_at`, `usage_limit_attempts`) and the `limit` hold | Cleared and released. A limit belongs to the harness process that hit it. Only a `limit` hold is released, so an operator's own hold on the id stands (see "Holds"). | -| The cached herdr status the next probe is diffed against | herdr's `unknown`, which matches neither branch, so the ship's first probe is a baseline. A carried status invents or erases transitions in both directions, and the same-status case is not self-correcting: equality is exactly what suppresses the announcement. | - -Genuinely pane-independent, and carried: - -| Fact | Why it survives | -|---|---| -| `report_offset` and `report_digest`, and the report channel they index | Promote never touches `state/.status`: the stream is continuous and the offset already points where the ship's first line lands. Resetting them would replay the scout's consumed lines. | -| `pr`, `merged`, `pr_merged_observed` | Facts about the branch and its PR, not about any pane. | -| `created_at` | The task's identity, which promote deliberately preserves - one task's lifecycle, not two. | -| The `parked` fired latch (`parked_fired_for`) | Keyed to the report mtime it fired for, and the report channel is itself carried. The restamped `pane_started_at` floors the ship's evidence time at the promotion instant, strictly later than anything the scout's silence could have fired against. | -| The report mtime `parked` measures silence from | Carried with the report channel, but *floored* at `pane_started_at`. A row written before that column existed is backfilled to its last observed `status_changed_at`, or `created_at` when no status was ever observed, which is the value the floor already computed for it. | - -**The floor reads `pane_started_at`, never `status_changed_at` or `created_at`.** -Only a fact written by spawn and by `hand promote`, and by nothing that merely observes the pane, answers "when did this pane start" without also answering "when was the last herdr transition seen". - -**The forget trigger is the task's herdr pane id differing from the one the cache was built against, not a status or a timestamp**, and it runs on every read of the task, including `syncTaskState`'s re-read under the task lock. -A promote can land after a tick's `state.List` snapshot and before its write-back, and that write-back also advances `report_offset`. - -Output (stream): -``` -idle-unreported dark-mode -blocked dark-mode: needs API key for third-party service -needs-decision fix-login: two ways to fix the race, ask-user found both risky -reported-done fix-login: PR https://github.com/org/repo/pull/42 checks green -stale investigate-crash -parked slow-migration: working: still on the migration (silent 42m) -failed api-refactor -pr-merged fix-login -``` - -The supervisory agent runs `hand watch` as a background task (via its harness's background-task mechanism) and acts on each printed line. -Streaming that way only reaches the agent if something prompts it to read; `--until-event` is how the watcher reaches it on its own. - -Event durability: if the supervisory agent's context compacts or the session restarts, events since the last read are in `state/events.log`. The agent can `hand status` to recover current truth and read `state/events.log` for recent history. - -Errors: -- Another watcher already attached to this fleet home: names the incumbent's pid on stderr and exits `3` before any polling, whether it refused outright or a `--takeover` could not confirm the incumbent was gone (see "One watcher per fleet home"). -- Herdr not running (fatal: exit `1`, the reachability probe answering with a failure). Under `--until-event` that probe is additionally raced against `--timeout` so a wedged daemon can't strand the wait, and *losing that race* is exit `4`, not `1` or `5`: the window closed with nothing delivered, which is what `4` means wherever in the process it happens, and stderr names herdr as what it was still waiting on. A signal during the same probe is `4` for the same reason. -- Individual task probe failure (graceful: report as "unknown" state, and entered into tracking with a dwell clock running rather than left out of it - see the `failed` bullet above). -- `--until-event` reaching its `--timeout`, or being signaled, without delivering an event: a line on stderr and exit `4`, never a silent exit `0`. This covers the timeout elapsing anywhere in arming - the herdr reachability probe as well as the per-task probe sweep - as well as during the poll: the window is over either way, and no one task is at fault. -- `--until-event` failing to arm because a task's herdr pane answers its probe with a failure: names the task on stderr and exits `5`, distinct from both `4` (no event: either arming succeeded and nothing happened, or the window closed mid-arm) and `0` (arming succeeded and something did). Unlike the streaming path's graceful "unknown" above, `--until-event` cannot tolerate an unprobeable task at all: one invisible to the arm-time probe never enters `states` and so can never produce the transition the caller is blocking on. A task first sighted after arming is not this case - it enters `states` through the live poll's own first-sighting tracking, dwell and all; see the `failed` bullet above. - -Why: `docs/adr/the-watcher-persists-what-it-announces.md`, `docs/adr/the-until-event-exit-is-the-delivery.md`, `docs/adr/one-watcher-per-fleet-home-guarded-by-an-flock.md`, `docs/adr/usage-limit-detection-is-a-harness-capability.md`, `docs/adr/notify-is-a-filtered-consumer-of-the-event-stream.md`, `docs/adr/a-silent-report-channel-is-parked-on-three-bounds.md`. - ---- - -### `hand project sync [name]` - -Fast-forward project clones to their remote default branch. - -``` -hand project sync -hand project sync nsr -``` - -Behavior: -1. For each project (or named project): - - `git fetch origin` in the clone. - - List `treehouse.toml` in the clone's `info/exclude` if it is not already, or its untracked pool - config reads as dirt and every sync from then on skips the project. - - If on default branch and clean: fast-forward to `origin/`. - - If dirty, on non-default branch, or diverged: skip with warning. -2. Prune local branches whose remote tracking branch is gone. - -Output: -``` -count: 3 -advanced: 1 -failed: 0 -projects[3]{name,result,detail}: - nsr,fast-forwarded,"origin/develop, was 3 behind" - yes2infra,skipped,dirty working tree - no-mistakes,up-to-date,none -``` - -`advanced` counts the clones that actually moved. A project whose sync errored outright carries no -row: with one named project that error is the command's exit, and across the whole registry it is a -stderr warning the run continues past, counted in `failed` and named by a `help[]` line. - ---- - -### `hand promote ` - -Promote a completed scout task into a ship task. -Reuses the existing task ID and brief, acquires a fresh worktree, and spawns a new worker. - -``` -hand promote investigate-crash -hand promote investigate-crash --harness codex -``` - -Flags: -- `--harness `: harness for the new ship worker. Default: value from `config/harness`. -- `--model `: model override. Default: the brief's declared `model`, else `config/model.`. -- `--effort `: effort override. Default: the brief's declared `effort`, else `config/effort.`. -- `--skip-gate-check`: dispatch into a `no-mistakes` project even if its gate is not initialized, its - clone path is missing from disk, or that path is not a git repository (see "Gate preflight"). - -Promote resolves model and effort exactly as `hand spawn` does, against the brief the agent updated -for the ship phase, and in the same position: after the task, gate and brief checks. - -Behavior: -1. Validate the task exists and is a completed scout (has `data//report.md`, herdr pane is not busy - `idle` or `done`, which mean the same thing here, see "Agent state" - or unreachable/dead). -2. Create or update `data//brief.md` - the agent should update it with implementation instructions before calling promote, referencing the scout report. -3. If the project's mode is `no-mistakes`, run the gate preflight check (see "Gate preflight"); - refuse before acquiring any worktree if it comes back not initialized or unreachable, unless - `--skip-gate-check` is set. -4. Acquire a fresh treehouse worktree (with collision guard). -5. Acquire the task's herdr tab in the project's workspace - same workspace-create-vs-reuse logic as `hand spawn` step 8, including reusing a freshly created workspace's own root tab instead of leaving it as an orphan. -6. Launch the worker and confirm it started (same as `hand spawn`). -7. Rewrite the task's row in place: `kind` changes from `scout` to `ship`, and `harness`, `model`, `effort`, `worktree`, `lease_id` and the `herdr` coordinates describe the new worker. Every field anchored to the scout's pane is reset: `done_verified` to false, `pane_started_at` and `status_changed_at` restamped to the promotion time with `status_changed_for` cleared, `usage_limit_retry_at` / `usage_limit_attempts` cleared, and `last_report_state` / `last_report_note` and `delivered_at` / `delivered_reason` emptied. A `limit` hold on the id is released alongside those columns (only that kind, and a failure to is a warning - the promotion itself has landed). Every pane-independent field is carried, including `created_at` and the watcher's `report_offset` / `report_digest` - see "Pane-anchored facts across `hand promote`", which classifies each of them and covers the matching in-memory cache a live `hand watch` has to drop. -8. Only now tear down the scout's herdr tab and return its worktree; a failure here is a warning, not an error. - -The scout side is torn down last: the same rollback contract as `hand spawn` applies up to step 7, -so a promotion that fails partway leaves the scout's pane and worktree intact. - -Output: -``` -id: investigate-crash -result: promoted -kind: ship -was: scout -project: nsr -harness: claude -worktree: /home/user/.treehouse/nsr-abc/2/nsr -help[2]: - - The scout's worktree and pane are gone; run `hand status investigate-crash` to read the ship worker - - The scout's delivery no longer counts for this task, so `hand deliver investigate-crash` runs again on the code -``` - -Errors: -- Task not found. -- Task is not a completed scout. -- Scout report not found. -- `no-mistakes` gate not initialized, or the binary missing/not runnable, for a `no-mistakes`-mode - project (same two distinct errors as `hand spawn`; see "Gate preflight"). Skipped entirely with - `--skip-gate-check`. -- Worktree or herdr errors (same as `hand spawn`). - -Why: `docs/adr/the-watcher-persists-what-it-announces.md`. - ---- - -### `hand notify ` - -Send an out-of-band notification. Uses the command template in `config/notify`. -The same `internal/notify.Send` this command calls is also the watcher's in-process notify hook - see "Delivering an -event to a supervisory agent" for that half. - -``` -hand notify "fix-login PR is ready for review" -``` - -Behavior: -1. Read `config/notify`. If absent, or empty once trimmed, nothing is delivered - see "Errors" below. -2. The notify config contains a shell command template. The message is available as the `$HAND_MESSAGE` environment variable. -3. Execute the command with `HAND_MESSAGE` set in the environment, under a 10s timeout: a template that hangs must not - hang its caller, which for the watcher's hook is the poll loop itself. -4. Print `notified: ` to stdout only once the command above has actually succeeded. A template that - backgrounds its work (`... &`) counts as succeeded once its own process exits `0`. - -Example `config/notify`: -``` -curl -s -X POST "https://api.telegram.org/bot$TELEGRAM_TOKEN/sendMessage" -d "chat_id=$TELEGRAM_CHAT&text=$HAND_MESSAGE" -``` - -Or for macOS: -``` -osascript -e "display notification \"$HAND_MESSAGE\" with title \"secondhand\"" -``` - -`hand config` does not cover `config/notify` - it owns `harness`, `model` and `effort` only - so a fresh fleet -home leaves the channel unconfigured. That absence is quiet in the watcher's hook (see "Notifying a supervisory agent -with no session watching") and loud here, per the exit code below. - -Output: -``` -result: notified -message: fix-login PR is ready for review -``` - -Errors: -- `config/notify` absent or empty, or its command failed or timed out - all exit `1`. An empty file is unconfigured - rather than a template. All four mean nothing reached the channel, so all are the same general error rather than a - warning behind exit `0`. - -Why: `docs/adr/notify-is-a-filtered-consumer-of-the-event-stream.md`. - ---- - -### `hand search [flags]` - -Full-text search the prose corpus under `data/`. - -``` -hand search login auth decision -hand search --json "no-mistakes gate" -hand search --rebuild deploy failure -``` - -Flags: -- `--fields `: which columns the `hits` block emits, in the order given. Any of `path`, `title`, `snippet`; all three by default. An unknown name is a usage error (`2`) naming the known ones. -- `--json`: output as JSON, one object per hit with `Path`, `Title` and `Snippet`. An empty result is `[]`, never `null`, so a caller can iterate without special-casing it. Rejects `--fields` as a usage error (`2`). -- `--rebuild`: discard and re-derive the index before searching. The recovery for an index that is present but wrong; an index that is simply *missing* needs no flag. -- `--limit `: maximum hits, default 20. - -Output: -``` -query: gate preflight -count: 2 -hits[2]{path,title,snippet}: - data/task-12/brief.md,Rework the gate preflight,... the gate preflight runs before ... - data/learnings.md,Learnings,... a gate preflight that cannot reach ... -help[1]: - - Read a hit's path for the whole document; the snippet is a window, not the match in full -``` - -Behavior: -1. Scan `data/` for markdown files, comparing each against the index by mtime and size, and index what changed. The refresh happens on every query rather than on a schedule, so no other command has to know the index exists. -2. Match against the FTS5 index, ranked by bm25, and emit `path`, `title` and a snippet per hit. -3. With no hits, stdout still carries the query, `count: 0` and the schema header, plus `help[]` lines naming the two things that produce an empty answer: a query too narrow to match, and a corpus the index never caught up with (`--rebuild`). -4. A result that came in exactly at `--limit` says so in `help[]`, naming the doubled limit that would widen it. - -Snippets are not truncated by `hand`: FTS5's own snippet window already bounds them to a fixed token -count, so the `--full` recovery shape `hand status` carries has nothing to recover here (see "Output -shape"). - -Every whitespace-separated token in the query is quoted before it reaches FTS5, so a query a supervisor would actually type - `no-mistakes gate`, `atqamz/secondhand#53` - is matched as literal text rather than parsed as query operators. - -The index lives in its own database at `state/index.db`, separate from machine state, and is safe to delete at any time (see "Machine state and the prose corpus"). Neither the search nor the rebuild reads `state/hand.db`. - -`data/dashboard.md` stays excluded from the corpus even though nothing writes it any more: a home initialized before the dashboard was deleted keeps its last render on disk indefinitely and no command refreshes it. - -Errors: -- Corpus unreadable (the rebuild names the file it could not read). -- Index unusable, past what a refresh repairs - the condition `--rebuild` exists for. - -Both are general errors (`1`), never usage errors: the operator typed the command correctly and the fault is in the world. - ---- - -### `hand doctor` - -Report-only check of the resolved fleet home's `AGENTS.md` for perishable content and generated-block drift. Fixes nothing; a human or agent reads the findings and edits the file. Findings are not all equal weight - see "Behavior" below for the one that is informational rather than exit-failing. - -``` -hand doctor -``` - -Behavior: -1. Resolve the fleet home (same resolution as every other command; a `hand doctor` outside one is the same precondition failure as elsewhere). -2. Scan `AGENTS.md` line by line, tracking fenced code blocks and the `hand:generated` span that delimits `internal/agentsmd`'s `generatedBody`, and flag: - - a date (`YYYY-MM-DD`) outside the generated span, since a date only stays true as long as the day it names, - - self-expiring phrasing outside the generated span - `until #N lands`, `once #N lands`, `awaiting #N` - the same shape of problem as a bare date. Each shape needs an issue to expire against, so a bare "awaiting" with nothing to anchor it is durable prose and is not flagged, - - an em dash or emoji anywhere in the file, generated span included, - - a code fence that is never closed, since it silences the date and self-expiring checks for every line after it, - - the generated span's content having drifted from `internal/agentsmd`'s `generatedBody`, a violation, - - the `hand:generated` markers being absent altogether, meaning nothing in `hand` will ever update this file's template. It is informational rather than a violation (see `agentsmd.Severity`), since a marker-less file can be an accident or a deliberate choice and nothing in the file tells the two apart. -3. Emit one row per hit under a `file` field carrying the resolved fleet home's absolute path to `AGENTS.md` (`generatedBody`'s absolute-path rule applies to the checker's own output too - a bare `AGENTS.md:12:` is ambiguous once more than one fleet home is in scope), and exit `1` if any violation-severity finding was found, `0` if the file is clean or every finding present is informational. - -Output: -``` -file: /home/you/fleet/AGENTS.md -count: 2 -violations: 1 -findings[2]{line,severity,finding}: - 12,violation,"a date (2026-07-29) outside the generated block goes stale" - none,info,no hand:generated markers - nothing in hand will ever refresh this file's template -help[2]: - - Edit AGENTS.md to resolve each finding; hand doctor reports and never rewrites - - Run `hand update` if the finding is generated-block drift, since that block is refreshed rather than hand-edited -``` - -`count` is every finding and `violations` only the exit-failing ones. A whole-file finding has no line -to anchor to and reads `none` there rather than `0`, which would read as line one. - -Flags: -- `--fields `: which columns the `findings` block emits, in the order given. Any of `line`, `severity`, `finding`; all three by default. - -A clean file is `count: 0`, `violations: 0` and the schema header, with no `help[]`. A run whose -findings are all informational passed, and says so in `help[]` rather than leaving a reader to infer -it from `violations: 0`. - -A remedy naming a command that takes a path spells that path out: `hand init` with no argument targets the working directory, which is a new nested fleet home whenever the operator ran `hand doctor` from anywhere but the home itself. - -A date or self-expiring phrase inside inline code (`` `...` ``) or a URL is not flagged, since it is documenting a fixed past event or literal text rather than making a claim about the present. - -A missing `AGENTS.md` is not an error: `hand doctor` reports its zero count and exits `0`, leaving `hand init` to be the one place that complains about an incomplete fleet home. - ---- - -### Optional: qmd for semantic search - -[qmd](https://github.com/tobi/qmd) adds what `hand search` deliberately does not do: semantic and hybrid search over embeddings. -It is never a dependency. `hand init` and `hand config` do not require or configure it, nothing in `hand` reads it, and every command works without it. -`generatedBody` names `qmd search` alongside `hand search` as a way to find historical context, and README carries the indexing commands. - -## Harness launch templates - -Each supported harness has a launch command template. -`hand spawn` constructs the command, `cd`s into the worktree, and sends it to the herdr pane. - -Every template must launch its harness **interactively**, never headless/one-shot. -`hand send` writes into a running pane, `hand watch` polls pane state to classify a worker as -working/blocked/idle, and the `no-mistakes` delivery mode drives many turns as the worker -responds to review/test/document/lint gates. -A one-shot process (`claude --print`, `opencode run`) answers once and exits: there is nothing -left to send to, classify, or drive through a gate. -Any harness template added here must stay resident across the whole task, and must have its -autonomy/permission flag set so an unattended worker does not stall on a permission prompt. - -### Claude Code - -```sh -cd && CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions "Read the brief at and carry out the task it describes. " -``` - -The brief path is included in the prompt because Claude Code takes prompt text, not a file path. -`` is `agentsmd.OperatorDecisionRule` verbatim, one exported constant rather than two copies that drift, appended to every prompt-carrying template because the worktree is outside the fleet home and the worker never reads the home's `AGENTS.md`. -When configured, `--model ` and `--effort ` are inserted before the prompt. -Claude and Codex take both model and effort; OpenCode takes a model but no effort; Grok and Pi take -neither (`harness.SupportsEffort`, `harness.SupportsModel`). -A declared value a harness has no flag for is warned about on stderr rather than dropped in -silence (see `hand spawn`). -When the brief carries a `---` declaration, the prompt gains a sentence disclaiming it as dispatch -metadata (see "Brief format"). -`--dangerously-skip-permissions` is required so the unattended worker does not stall on a -permission dialog. -`CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false` suppresses the dim predicted-next-prompt ghost text -Claude Code renders while idle; without it, a supervisor reading the pane can misread ghost text -as the worker having typed input. - -Interactive launch has first-run dialogs that headless `--print` skipped. `internal/harness`'s -`FirstRunPromptsFor` is the authority for which are catalogued and how each is matched; the policy -is: - -- An answerable dialog is answered automatically, at most once per launch. The workspace trust - dialog is one of these, and it appears on every spawn rather than once per host, since every - treehouse worktree is a fresh path under the pool root. -- The managed-settings security dialog is recognized and deliberately not answered: accepting it - grants arbitrary code execution and prompt interception for every run on the host, which is not - `hand`'s grant to make. The operator accepts it once on the host, then respawns. -- A signature must stay case-sensitive and keep its distinguishing anchors - `Bypass\s+Permissions\s+mode`, - not a bare `bypass permissions`, which Claude Code's status line contains permanently. - -`hand spawn` and `hand promote` poll the pane after sending the launch command. Liveness is herdr's -answer and never the screen's; pane text is read from recent scrollback (`pane read --source recent`) -only to spot dialogs. Success needs a live agent on the pane, free of both catalogued dialogs and the -generic unrecognized-dialog fallback, for the settle window. - -Two outcomes are not success. A pane with no agent, or one still showing a dialog, when the poll -window elapses fails the spawn or promote with that pane content and what held it up. A -recognized-but-refused dialog fails immediately, naming what a human has to accept. See -`cmd/launch.go`'s `confirmLaunch` for the polling and timeout values. - -A harness with no catalogued signatures at all is confirmed on agent presence alone, so an agent -parked on a dialog `hand` cannot recognize is reported as started. That is a known, accepted gap, and -the reason the catalogue matters for every harness added rather than only for claude. - -### Codex - -```sh -cd && codex --dangerously-bypass-approvals-and-sandbox -c 'disable_paste_burst=true' --model -c 'model_reasoning_effort=""' "Read the brief at and carry out the task it describes. " -``` - -The template was verified against Codex CLI 0.146.0. `--model` and the reasoning-effort override -are omitted when unset; effort `auto` also omits the override so Codex inherits its own default. -Disabling paste-burst buffering keeps an immediate Enter from `hand send` from being absorbed. - -### Grok - -```sh -cd && grok --trust --file "" -``` - -### Pi - -```sh -cd && pi "" -``` - -### OpenCode - -```sh -cd && OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}' opencode --prompt "Read the brief at and carry out the task it describes. " -``` - -The bare `opencode` command opens its interactive TUI, unlike `opencode run`, which is -explicitly headless and exits after one reply. -`OPENCODE_CONFIG_CONTENT` grants blanket tool permission so the unattended worker does not -stall on a permission prompt. -When configured, `--model ` is inserted; the bare command has no effort/variant flag, so -`--effort` has no effect on OpenCode workers. -The bare command also has no `--file` flag, so the brief path is embedded in the prompt text -instead of attached. - -The Claude, Codex and OpenCode forms above were verified against the installed CLI versions. -Grok and Pi retain unverified templates until those binaries are installable; whoever -verifies them must confirm interactive (not headless) launch, not just flag names. -`internal/harness` is the single place that constructs these commands. -A template that hands the brief over as a file rather than as prompt text (Grok, Pi) has no -prompt to append to, so `agentsmd.OperatorDecisionRule` and the front-matter disclaimer never reach -those workers: the brief is all they read. `harness.CarriesPrompt` reports this, and `hand spawn` -warns on stderr rather than dropping it in silence. - -Why: `docs/adr/harness-templates-launch-interactively.md`, `docs/adr/launch-confirmation-trusts-herdr-not-the-screen.md`. - -## Herdr integration detail - -### Connection - -`hand` connects to herdr via its CLI (`herdr`) and/or its HTTP API. -The herdr server must be running before any `hand` operation that touches tabs/panes. - -### Workspace and tab model - -- One workspace per project. Workspace label = `hand:`, never the bare project name, - which is a namespace every directory on the machine can write into. A workspace already created - under the bare label is not adopted: `hand spawn` creates a new `hand:` workspace - alongside it, and the old one is orphaned - still functional, just no longer found by lookup. -- One tab per task within the project workspace. Tab label = task ID. -- The supervisory agent's own session is a separate herdr workspace (or the user's own terminal). - -Why: `docs/adr/herdr-workspace-labels-carry-a-hand-prefix.md`. - -### Agent state - -herdr tracks agent state per pane, with five values: `working`, `idle`, `blocked`, `done`, `unknown`. -`working` and `blocked` mean what they say, and `unknown` is herdr's own degrade-gracefully value. - -`done` does not mean the task is done. `idle` and `done` are one not-busy transition herdr labels by -whether a human happened to be looking (see `internal/faketool/FIDELITY.md` for the observed -behavior), so neither carries task-outcome information for a headless fleet. `hand` treats them -identically - both mean the pane stopped being busy - and never infers completion from either. The -report channel (see "Report channel") is the only source of task outcome, not a supplement to this. - -`hand status` queries this directly. -`hand watch` subscribes to state changes. - -Why: `docs/adr/the-report-channel-is-the-only-outcome-signal.md`. - -### Operations - -| hand command | herdr operation | -|---|---| -| `hand spawn` | create workspace (if needed, at the worktree's cwd, reusing its own root tab as the task tab) or create tab in an existing workspace + send launch command + poll pane state and read pane text until the worker is confirmed started, sending keys to answer first-run dialogs | -| `hand status` | get agent state for pane | -| `hand send` | poll pane until the composer is empty, bounded by `--wait` + send keys to pane | -| `hand teardown` | close tab (+ close workspace if empty) | -| `hand watch` | subscribe to agent_status_changed events, or poll pane states | - -### Herdr CLI calls - -```sh -# list workspaces -herdr workspace list - -# create workspace without focusing it - herdr always creates a root tab and pane too, at --cwd, -# which hand points at the worktree and reuses as the first task's tab. -# --env blanks the CLAUDE_CODE_CHILD_SESSION/CLAUDE_CODE_SESSION_ID/CLAUDECODE the herdr server may -# have inherited, so it never hands its own session identity to a pane it creates -herdr workspace create --no-focus --cwd --label hand: \ - --env CLAUDE_CODE_CHILD_SESSION= --env CLAUDE_CODE_SESSION_ID= --env CLAUDECODE= - -# create tab in an already-existing workspace - same sanitized --env set as workspace create above -herdr tab create --workspace --no-focus --cwd --label \ - --env CLAUDE_CODE_CHILD_SESSION= --env CLAUDE_CODE_SESSION_ID= --env CLAUDECODE= - -# rename a tab - used to turn workspace create's own root tab into the first task's tab -herdr tab rename - -# list tabs in workspace -herdr tab list --workspace - -# get pane and agent state -herdr pane get -# returns agent: detected harness name, empty when no harness runs in the pane -# returns agent_status: "working" | "idle" | "blocked" | "done" | "unknown" - -# read the pane's recent scrollback as plain text -herdr pane read --source recent --lines - -# run a command in pane -herdr pane run - -# send text and submit it -herdr pane send-text -herdr pane send-keys Enter - -# close tab -herdr tab close - -# close workspace (if empty) -herdr workspace close -``` - -These calls and their responses were verified against the installed herdr version. -Responses come in two shapes, and the client validates each one differently: - -- **Query commands** print a JSON envelope carrying a non-null `result` object; a missing or null - result is an error. - `tab close` and `workspace close` belong here too: they answer with a `{"type":"ok"}` result - rather than staying silent. -- **Void commands** (`pane run`, `pane send-text`, `pane send-keys`) print nothing on success. - A failure prints a JSON error envelope whose exit code cannot be trusted on its own, so any - non-empty body is parsed for that envelope before the exit status is consulted. -- **`pane read`** is a third shape: plain pane text on success, and on failure a bare - `{"code","message"}` object rather than the `{"error":{...}}` envelope the other two shapes use. - -The herdr client abstracts these into Go function calls; `internal/herdr` keeps one entry point -per shape and is the source of truth for which command uses which. - -## No-mistakes integration - -No-mistakes is an external Go binary. -Secondhand does not wrap it. -The worker uses `no-mistakes` directly in the worktree: - -- **Initialization:** `hand project add --mode no-mistakes` runs `no-mistakes init` in the clone. Treehouse worktrees inherit the gate. -- **Validation:** The worker runs `no-mistakes axi run` in its worktree. `axi` is no-mistakes' built-in agent interface (non-interactive, token-efficient output). It is not a wrapper tool. -- **Gates:** When `no-mistakes axi run` parks at a gate (review approval, fix review), it prints the gate state. The worker reads it and either resolves it (`no-mistakes axi respond`) or reports blocked to the supervisory agent via herdr state. -- **Status:** `no-mistakes axi status` shows the current run state. -- **Abort:** `no-mistakes axi abort` cancels an active run. - -Secondhand's only no-mistakes awareness: -- `hand project add` initializes it when `--mode no-mistakes`. -- The ship brief template mentions the no-mistakes workflow when the project mode is `no-mistakes`. -- `hand spawn` and `hand promote` ask `no-mistakes status` before dispatching into a `no-mistakes` - project, refusing if the gate is not initialized for that repo (see "Gate preflight" below). This - is a read-only status check, not driving the pipeline: `hand` still never calls `axi run`, - `axi respond`, or `axi abort`. The worker does. - -### Gate preflight - -`no-mistakes`'s own state is keyed on the absolute `working_path` of the repo it was initialized -against, and two histories orphan that row silently: the fleet home gets renamed, or a project is -registered with `--mode no-mistakes` but `no-mistakes init` is never run against it. - -`hand spawn` and `hand promote` each run a preflight check before dispatching into a `no-mistakes` -project: `no-mistakes status`, run inside the project's clone. It always exits 0, initialized or not, -and reports both of those histories with the identical text `repo not initialized (run 'no-mistakes -init' first)`, so the preflight does not try to tell them apart. Every outcome is read from that text, -never from `~/.no-mistakes/state.sqlite`. - -Five outcomes: -- **Initialized:** proceed. -- **Not initialized** (either history): refuse with exit code 3, naming the exact remedy verbatim - - `no-mistakes init` is idempotent and repairs a stale `working_path` in place, so the message reads - `no-mistakes gate not initialized for project "", run: cd && no-mistakes init`. -- **Binary missing or not runnable:** refuse with a distinctly different message (`no-mistakes - binary not found or not runnable: `), never collapsed into "not initialized" - the remedy - for a missing binary is not `no-mistakes init`. This is a general error (exit code `1`), not a - precondition: the world is not in a state the operator can fix by initializing anything. -- **Clone path does not exist on disk:** refuse with `no-mistakes clone path: `, naming - the real cause instead of the misleading "binary not found or not runnable" a chdir failure used - to produce. Also a general error (exit code `1`). -- **Clone path exists but is not a git repository:** `no-mistakes status` exits 0 and prints `not in - a git repository` here, a plain-looking success. Refuse with `no-mistakes clone path is not a git - repository: `, also a general error (exit code `1`), never `GateReady`. - -The non-git case is read from `no-mistakes status`'s own text like every other outcome here. The -missing clone path is caught by stat-ing that path before the binary is run at all. - -Escape hatch: `--skip-gate-check` on both `hand spawn` and `hand promote` bypasses the preflight -and prints a warning to stderr naming the project, so bypassing it is visible in the transcript -rather than a silent env var. - -`hand project list` runs the same check for every `no-mistakes`-mode project and carries `not -initialized` or `unreachable` in that project's `gate` column (and `"gate_issue"` in `--json` -output) when the check doesn't come back clean, counting them in `gate_issues`, so a stale or -never-initialized gate is visible without waiting for a spawn or promote to refuse. - -### Gate-run visibility - -Gate preflight answers whether a project's gate is initialized. It says nothing about whether any -given shipped PR went through it: a gate can be ready and still never have run against the branch a -task's PR came from. - -`hand status` answers this with `project.GateRunPRs(clonePath)`: run `no-mistakes runs --limit -10000` in the project's clone and collect the PR URL each `completed` row recorded for itself, the -same read-only text scraping `GateStatus` uses, never `~/.no-mistakes/state.sqlite`. A PR is gated -when it is exactly one of those URLs. The answer is per clone, not per PR, and cached for the length -of one render. - -It establishes only that the no-mistakes `pr` step opened this exact PR from a run that reached -`completed`, and the wording is deliberately no stronger: -- It is not a per-commit answer. `no-mistakes` keys on `working_path`, not per PR, and `hand` records - no head commit to compare against, so a push to the same branch after the matched run still reads - as gated. -- A PR opened by hand outside the `pr` step reads as `no run found` even sitting behind a run that - did complete: nothing ties that URL to that run's own bookkeeping. -- A PR with no completed run recording that exact URL reads as `no run found`. A failure to ask - no-mistakes at all reads as `unreachable`, the same bucket `gateIssue` uses in `hand project list`, - so a question the check could not answer never renders as the stronger claim. That bucket covers a - missing clone, an unrunnable binary, a gate that was never initialized or whose `working_path` went - stale, and a clone path that is not a git repository - each read from `no-mistakes runs`'s own - output text rather than its exit code. - -`hand status` never says "this PR is safe" or "this PR is gated", only whether a matching completed -run was found. - -The check applies to a `done` `ship` task with a recorded PR on a registered `no-mistakes` project -only; every other task has nothing this check can say, so it stays silent. - -Why: `docs/adr/gate-checks-read-no-mistakes-output-not-its-database.md`. - -## Brief format - -`data//brief.md` is written by the supervisory agent before `hand spawn`. -`hand spawn` validates it exists but does not generate or modify it. - -The brief is freeform markdown. The agent writes whatever the worker needs. -Recommended structure: - -```markdown -# Task: - -## What to do - - -## Acceptance criteria - - -## Constraints - - -## Context - -``` - -There is no template engine, no placeholder substitution, and no generated sections. - -### Declared model and effort - -A brief may open with a `---` fenced block declaring the tier the task should run at: - -```markdown ---- -model: claude-opus-5 -effort: high ---- - -# Task: -``` - -Both keys are optional, and the block is only recognized when `---` is the brief's very first -line. `hand spawn` and `hand promote` read it (`internal/brief`) and resolve model and effort as -flag, then declaration, then config default, then unset. The brief is the durable statement of -scope, so a respawn or a promote picks the declaration up again instead of falling back to -`config/model.`. - -The parser is deliberately forgiving, unlike `data/projects.md`'s registry parser: unknown keys -inside the block are ignored, a value written as a YAML quoted scalar (`model: "claude-opus-5"`) -declares the same tier as the bare form, and a brief it cannot scan (an unterminated fence, an -enormous pasted line) is read as having no declaration rather than failing the spawn. Model names -are not validated against a list, which would rot the first time a model ships. - -The declaration is dispatch metadata, not task content. The launch prompt gains one sentence marking -the block's `model` and `effort` keys as such when a block is present; anything else the block -carries is left to the worker to read, and the brief on disk is never rewritten or stripped. `grok` -and `pi` are handed the brief as a file with no prompt at all, so a declaring brief reaches them -undisclaimed; every other harness carries the sentence in its launch prompt. - -A declared effort under a harness that cannot apply one warns on stderr, as does a declared model -under `grok` or `pi`, and so does the operator-decision rule and the front-matter disclaimer those -same two cannot carry. Whatever a given launch drops is named on one combined -line, never one line per dropped value (see `hand spawn`). - -## Backlog format - -`data/backlog.md` is a plain markdown file edited directly by the supervisory agent. - -```markdown -# Backlog - -## Queue -- fix-login: nsr - fix flaky login test -- dark-mode: nsr - add dark mode toggle (depends: fix-login) -- api-cache: yes2infra - add API response caching (after: 2026-08-01) - -## In Progress -- investigate-crash: nsr - investigate production crash reports | scout - -## Done -- fix-typo: nsr - fix README typo | PR https://github.com/yes2games/nsr/pull/40 -- audit-deps: nsr - audit outdated dependencies | report data/audit-deps/report.md -``` - -Conventions: -- Each item: `- : - [| ]` -- Dependencies: `(depends: )` in the description. -- Date gates: `(after: )` in the description. -- Artifacts: `| PR ` or `| report ` or `| scout` suffix. -- The agent moves items between sections as work progresses. -- Finished items roll off `## Done` into `data/done-archive.md`, and dropped or superseded ones into `data/note-archive.md` with the reason they were dropped, rather than being deleted. - -`hand` reads this file for: -- `hand spawn` can optionally warn if the task ID isn't in the backlog (non-blocking). -- `hand teardown` can optionally update Done items (but the agent can do this itself). - -The backlog is the agent's document. `hand` is a consumer, not an owner. - -## Project registry format - -The registry is machine state, so the store owns it and `data/projects.md` is its projection, rewritten by `hand project add/remove`. -The file is still what an operator reads, so the projection preserves the file: comments, prose and ordering survive a rewrite, a re-registered project keeps its place, and a new one goes at the end. -Hand-editing it is not the way to register a project - the next write rewrites the file from the store - and a pre-sqlite file is imported once (see "Migration"). - -```markdown -# Projects - -- nsr: https://github.com/yes2games/nsr mode=direct-pr -- yes2infra: https://github.com/yes2games/yes2infra mode=no-mistakes -- secondhand: local mode=local-only -- no-mistakes: https://github.com/atqamz/no-mistakes mode=direct-pr upstream=kunchenguid/no-mistakes -``` - -Fields: -- ``: project identifier, used in all `hand` commands. -- URL or `local`: git remote URL or `local` for repos without a remote. -- `mode=`: delivery mode. -- `upstream=`: optional. The repo this project's PRs are opened against when it is a fork. - Absent for the ordinary case, where a project contributes to its own repo. - A URL form is accepted and normalized to the slug; an `upstream=` that cannot be resolved to one refuses the line rather than importing a project whose upstream could never match. A slug can never contain whitespace, which is what keeps this projection round-trippable. - -Delivery modes: -- `no-mistakes`: worker runs no-mistakes pipeline, ships via PR with validation evidence. `hand - spawn` and `hand promote` refuse to dispatch into a `no-mistakes` project whose gate is not - initialized (see "Gate preflight"). -- `direct-pr`: worker pushes branch and opens PR directly. -- `local-only`: worker commits to a local branch. Merging into default branch via `hand merge --local`. - -## State management - -### Rules - -- **sqlite, not one JSON file per task.** Machine state lives in `state/hand.db` (see "Machine state and the prose corpus"). One row per task and per project, queried rather than globbed, with the whole registry consistent at every read. Pre-sqlite `state/.json` files are imported once and moved to `state/migrated/`; see "Migration" below. -- **Current state, not append-only logs.** One row per task, updated in place. History comes from `state/events.log` and herdr event streams, not from accumulating status lines. `state/completions.jsonl` is the one exception: it is not a task's current state but durable history of a state that no longer exists, the only record of a completion once `hand teardown` removes the row. -- **Nothing durable is derived from a rendering.** Every view - `hand status`, the watcher's classification - is computed from the store and the report channel at the moment it is asked for. No code path reads a previous rendering back in as evidence. -- **No separate status files for herdr-visible state.** The worker's herdr-visible state (working/idle/blocked/done/unknown) is queried from herdr in real-time, not persisted by `hand`. The store tracks static metadata (project, worktree, harness, PR URL), not dynamic agent state. The one exception is `state/.status`, the worker-to-supervisor report channel (see "Report channel" below), which carries what herdr cannot: why a worker stopped and what happened. -- **Event log for crash recovery.** `state/events.log` is a bounded rotating log (last 200 lines) of actionable watcher events. Not for real-time consumption - the watcher prints to stdout for that. The log exists so a restarted agent can read recent history that happened while its context was down. -- **Holds are their own table, not a task column.** See "Holds" below. - -Why: `docs/adr/the-completion-store-is-an-uncapped-append-only-sibling.md`, `docs/adr/the-report-channel-is-the-only-outcome-signal.md`, `docs/adr/holds-are-their-own-table.md`. - -### Report channel - -`state/.status` is an append-only text file the worker writes and `hand` only ever reads. -The brief the supervisory agent writes for a worker must include this file's absolute path and the vocabulary below, so the worker knows to append to it. -It lives and dies with the task: `hand teardown` removes it alongside the task's row, so an ID respawned later starts with an empty channel rather than inheriting the previous run's log (see `hand teardown`). - -Each line has the shape `: `, one state transition per line: - -``` -working: added the retry loop -needs-decision: two ways to fix the race, ask-user found both risky -done: PR https://github.com/org/repo/pull/42 checks green -``` - -Fixed vocabulary (anything else is malformed, and malformed lines are surfaced, never silently dropped): - -- `working`: the worker is actively making progress. `` is a short description of what it's doing. -- `paused`: the worker stopped without being blocked or done (e.g. waiting on something time-based). -- `blocked`: the worker is stuck and needs help. `` is the reason. -- `needs-decision`: something requires supervisor or human judgment the worker isn't authorized to make alone (e.g. an ask-user finding from `no-mistakes`). `` is the decision needed. -- `done`: the worker believes the task is complete. `` should include the PR URL for ship tasks. -- `failed`: the worker gave up. `` is why. - -Only a `hand send` message carries an operator decision. -A worker answering its own harness's question dialog is deciding for itself and must never write that answer as if the operator said it. -There is no separate vocabulary word for this: the worker records it as `working: deciding myself: because `, first person, and reserves `needs-decision:` for what it cannot take back itself. - -Read/classify semantics: - -- `hand watch` tails the file once per task per poll tick from a byte offset persisted as `report_offset` on the task, classifying only whole, newline-terminated lines. A partial trailing line is left unconsumed until the next tick. The offset is durable, so a restarted `hand watch` replays no already-surfaced line into stdout or `state/events.log` and drops no line written moments before the restart. The last state and note a line classified to are carried across a restart as `last_report_state` and `last_report_note` rather than re-read from the file - see "What survives a `hand watch` restart". -- Blank and whitespace-only lines are skipped by every reader. -- If the file shrinks below the last known offset (recreated, truncated), tailing restarts from the beginning rather than erroring. -- The reader tolerates the channel being rewritten in place rather than appended to. Every offset the reader persists sits immediately past a newline, so an offset whose preceding byte is no longer a newline is stale and tailing restarts from the beginning. -- **The offset is trusted only together with a digest of what it consumed**, persisted as `report_digest` beside it. The digest covers exactly the consumed prefix, never the unconsumed tail a worker may still be writing, and a digest that no longer matches discards the offset with it and tails from the beginning. An empty digest - a row written before the column existed, or a task whose worker has yet to report a line - falls back to the newline check alone; the first tick that consumes a line records the digest. Nothing outside the file is consulted. -- Each classified line becomes a `report-*` event (see `hand watch`) and updates the task's last-known report state, which `hand watch`'s idle classifier and `hand status`'s report suffix both consult. Both answer from the last line that *classified*, never simply the last line - `hand watch` by only advancing its carried state on one, `hand status` by skipping trailing malformed lines when it re-reads the file. -- **A `done` report is never trusted alone.** It is cross-checked against completion evidence the worker didn't produce before it changes agent state or clears a pending decision, and until then it surfaces as "reported-done", not "done". Each task kind has its own evidence: a ship task's merge (`merged` written by `hand merge`, whichever route it took - a PR merge or a `--local` fast-forward that leaves no PR at all - or a recorded PR the watcher's own `gh pr view` poll saw merged), and a scout task's `data//report.md`. The ship check never asks which mode the project uses. Evidence usually arrives after the `done` line is consumed, so the watcher re-checks every tick and fires the verified `done` event once, when the evidence lands - including when it landed while the watcher was stopped, since the announcement is tracked by the durable `done_verified` marker (see "What survives a `hand watch` restart"). -- A line carrying exactly one PR URL auto-records it on a task that doesn't have one yet, exactly as if `hand pr` had been called, including `hand pr`'s full validation (repo-slug match against the project clone's origin remote, plus the `gh pr view` existence check). Both paths call the one shared `project.ValidatePR`. Neither kind of miss aborts the watcher: an attempted recording that did not complete raises `pr-not-recorded` with the underlying error appended, flattened onto the event's single line; one the task lock kept the watcher from even attempting raises `pr-record-unknown`, which claims nothing about the outcome and points at `hand status`. The report line is consumed either way, so both go to the event stream and `state/events.log` rather than only to stderr. Losing the lock race to the `hand pr` recording that very URL is not a failure: the watcher re-reads the task and says nothing when the URL is already on record. A line with more than one URL, or a task that already has a PR recorded, is left alone, so `hand pr`'s own explicit-mismatch refusal stays the single path for correcting a wrong record. - -#### Unacknowledged terminal reports - -A task whose report channel carries a terminal state past the watcher's own durable `report_offset` carries the `unacknowledged` token in the `flags` field of both `hand status` views, `done: (unacknowledged)` in the detail view's `report` field, and `"unacknowledged": true` in either `--json` shape - omitted when false. -When the flag applies, the detail view's `report` field names the classified terminal report rather than a later unclassified line, the same state the `reported` field names; the worker's literal last line is still shown there when the flag does not apply, and appears in `report_history` either way. -Both views derive the flag from the whole report file, never from the detail view's 5-line history window. - -**`report_offset` is the marker; there is no second one.** Advancing it already means announced: the poll loop persists it only after the tick's events are announced (behavior step 11 under `hand watch`). -A terminal line past the offset therefore reached nobody, and one behind it reached at least the durable log. -`report_digest` is not a second marker: a rewritten channel whose digest no longer matches reads as entirely unacknowledged. - -Only the last classified line of the unconsumed tail counts, so a `done` a worker followed with more `working:` was superseded rather than missed, and a resumed worker's second `done` is flagged again on its own terms even though the first was consumed. -A terminal line the worker never terminated with a newline counts too, and this is the one place a reader deliberately parts company with the watcher, which leaves an unterminated line unconsumed for its next tick. - -Why: `docs/adr/the-report-channel-is-the-only-outcome-signal.md`, `docs/adr/the-report-offset-is-trusted-only-with-a-digest.md`. - -### Holds - -A hold records that an id is waiting on something, so "what needs the operator" is derived from the store rather than authored by hand. -`data/backlog.md` is out of scope for holds entirely. - -**A hold is its own row, keyed by an arbitrary id, not a foreign key into the task table.** A hold outlives the task row `hand teardown` deletes, and an id with no task row behind it can carry one. - -Three kinds, no others invented without a new issue: -- `operator`: waiting on a human. `reason` says what for. -- `blocked`: waiting on another id. `reason` says what for, `blocked_on` names the id. -- `limit`: waiting on the harness's own quota. Set and cleared by `hand watch`, never by an operator. - -Set with `hand hold set`, which upserts - a second call on the same id replaces its kind, reason, and blocked-on. Cleared with `hand hold clear`, which deletes the row outright. - -**`limit` is the one machine-set kind, and it is a projection rather than a record.** `hand watch` sets it when a worker's harness stops on a usage limit and clears it when that worker runs again; the durable schedule that actually resumes the worker lives on the task row, not here (see "Resuming a usage-limited worker" under `hand watch`). -Its rules therefore differ from the other two in both directions: `hand hold set --kind limit` is refused with exit 2, while `hand hold clear` accepts it. -`hand watch` and `hand promote` clear it only when the hold on that id really is of kind `limit`, and `hand watch` writes it only when the id holds nothing or already holds a `limit` one. -The attempt that would have refreshed the projection but yielded says so on the watcher's diagnostic stream instead. - -**Surviving teardown makes id reuse a hazard, so `hand spawn` refuses a held id** with exit 3, naming `hand hold clear ` rather than clearing it silently. -There is no `--force`. - -`limit` is the one kind that does *not* outlive the task it was set on, and `hand teardown` releases it (again, only when the hold really is of that kind). - -**A hold that cannot be read must never read as nothing waiting.** `ListHolds`/`ReadHold` surface every row exactly as stored, inconsistent ones included, and `hand status` flags an inconsistent row (an unrecognized `kind`, a `blocked` hold with no `blocked_on`, or an `operator` hold carrying one) rather than rendering it as if it were valid. -A store-level failure to read holds at all - not a single bad row, the whole read - propagates as a hard error out of `hand status`, fleet or single-task, rather than degrading to an empty list. -It is the one place in `hand status` that does not fail open on a read. - -Why: `docs/adr/holds-are-their-own-table.md`. - -### Concurrency - -- Each task is one row. Writes go through sqlite, which serializes them; `hand`'s own named `flock`s (task, project, worktree, send) sit above that and guard whole command sequences, which a per-statement database lock cannot. The send lock is its own name rather than the task lock because it is held for the whole of a `hand send`'s composer wait (see `hand send`). The project lock keeps the `data/projects.md` projection whole across the read-modify-write that renders it. -- `hand watch` is the only long-running process; all other commands are short-lived. It is also the only singleton: at most one watcher per fleet home, enforced by an `flock` held for its whole lifetime (see "One watcher per fleet home"). -- Multiple `hand` invocations against different tasks are safe in parallel. -- Multiple `hand` invocations against the same task should be avoided (agent discipline, not locking). -- **Concurrent tasks on same project:** allowed. Each gets its own treehouse worktree, kept off every other task's by treehouse's own pool lock; the collision guard in `hand spawn` and `hand promote` is defense-in-depth over `hand`'s bookkeeping on top of that (see "Collision guard"). File-level conflicts are resolved at merge time, not at spawn time. Avoiding tasks that touch the same files is a judgment call, not an enforced constraint. -- **No session lock.** Multiple supervisory sessions can run `hand` commands. sqlite's own locking prevents corruption; duplicate work is an agent-level problem, not a CLI-level problem. -- **No daemon and no connection pool.** Every command opens the database, does its work and closes it, on a single connection (see "Not Postgres, and no daemon"). - -### Collision guard - -`hand spawn` and `hand promote` both acquire a worktree and then cross-check it against every other task row before committing to it. -What they compare is the lease identity treehouse mints per acquisition (`lease_id` in `treehouse get --lease --json`, recorded on the task row), not the worktree path, which treehouse recycles. - -Path comparison remains the fallback whenever either side has no identity - a task row written before the `lease_id` column existed, or a treehouse older than v2.1.0, which is the version floor for the field. -Existing rows therefore keep being guarded through the migration and gain a real identity as each task is torn down and respawned. - -Every task row is compared, done and failed ones included, because a task keeps its lease until teardown returns it. - -The guard is defense-in-depth over `hand`'s own bookkeeping, not the thing preventing two tasks from sharing a worktree: `worktree.Get` always passes `--lease` and treehouse's pool lock refuses a currently-leased slot. - -Why: `docs/adr/the-collision-guard-keys-on-the-lease-id.md`. - -### Recovery - -On restart (new supervisory agent session): -1. Agent runs `hand status` to see active tasks with current herdr state. -2. Optionally reads `state/events.log` for events that happened during the gap. -3. For each task, herdr state shows whether the pane is busy (working/blocked), not-busy (idle/done - see "Agent state" for why these carry no task-outcome signal by themselves), unreachable (unknown), or dead. -4. Dead herdr pane = dead worker. Agent decides: respawn or teardown. -5. No special recovery logic in `hand`. The CLI shows state; the agent decides action. - -When `hand` itself is the thing that is broken - a stale binary, a database that will not open - none of the above is available, and the recovery is `cat state/.status`. -There is no `hand dump` (see "Which to believe when they disagree"). -A corrupt `state/index.db` is not a recovery situation at all: delete it, and the next `hand search` rebuilds it from `data/`. - -Why: `docs/adr/believe-the-status-file-and-ship-no-hand-dump.md`. - -### Migration - -An existing fleet home has live state on disk, and the import has to meet it without a working previous binary. - -- On first open, `hand` imports every `state/.json` it finds by reading the JSON directly - not by asking the old binary for anything - and moves each imported file into `state/migrated/`. The files are kept rather than deleted so an operator can still read what was imported, and moved rather than left in place so `state/` never holds a second file that looks authoritative. -- `data/projects.md` is imported the same way, once. Unlike the task files it survives the import as its own projection, so its absence cannot serve as the done marker; a `migrated:projects.md` row in the store's `meta` table serves instead. -- **The import is idempotent.** A second run finds no JSON left to import and a registry already marked imported, and changes nothing. -- **A row already in the database wins over a file.** A legacy file that reappears - restored from a backup, or copied back out of `state/migrated/` - is a snapshot from before the import and must never overwrite what `hand` has recorded since. -- **The whole import runs under one named lock**, the same primitive that guards a command sequence elsewhere, since it spans files sqlite cannot see. It is a lock of its own, not the project registry's, which `hand project add` and `remove` already hold when they trigger the registry import. -- **A legacy file that will not parse stops the import and names the file**, rather than importing the rest and leaving an operator to notice a task went missing. Moving the named file aside is the way forward. -- There is no reverse migration. The `state/migrated/` copies are what a rollback would read. -- **A new table needs no migration step of its own; a new column does.** `Open` runs the whole `schema` string on every open, and its `CREATE TABLE IF NOT EXISTS` adds a table an existing database is missing outright but is a silent no-op for a missing column. See "Schema versioning" below. - -### Schema versioning - -`Open` gates every other statement on `PRAGMA user_version`, sqlite's own built-in counter: no extra table, free to read, and part of the database file itself. - -- **Version 0 is the schema the `schema` constant in store.go builds**, the baseline every existing `state/hand.db` already carries, since sqlite defaults an unset `user_version` to 0. It means "the baseline schema this commit ships", not "unknown, refuse to proceed". -- `migrations` in schemaversion.go is an ordered list of SQL statements, one per schema change since that baseline, each moving `user_version` from its index to index+1. An ordinary column addition is two edits that stay in step: the column goes into the `schema` constant, so every database created from then on is built with it, and the matching `ALTER TABLE` is appended to `migrations`, so every database that already exists gains it on its next open. Nothing else in the package needs hand-written detection logic for it. -- A column whose empty default would be wrong for a row that already exists takes a third edit: the entry carries a backfill `UPDATE` alongside its `ALTER TABLE`, and `readLegacyTask` computes the same value, since a legacy `state/.json` import lands as an `INSERT` no migration step ever runs over. `task.pane_started_at` is that case; `task.usage_limit_retry_at`/`usage_limit_attempts` are the ordinary two-edit one, where an empty retry stamp already means "not waiting on quota". -- **A brand-new database never replays migrations.** `migrateSchema` checks for the `task` table before running `schema` - absent means the file has never had a schema at all - and on that path creates the tables and stamps `user_version` straight to `len(migrations)`, both in one transaction so a crash cannot leave a home carrying the migrated columns while still reading as version 0. -- **A database newer than the binary is refused, not guessed at.** If `user_version` exceeds `len(migrations)`, `Open` fails wrapping `ErrSchemaNewer` before running a single statement against the tables. -- **Applying pending migrations takes a lock**, `SchemaLock` in lock.go, because sqlite's per-statement locking cannot make "add this column, then bump `user_version`" atomic across a whole `Open`. Both processes re-check the version after acquiring it, so whichever loses the race applies nothing. -- Each pending step on a database that already exists runs in its own transaction, after the baseline `schema` exec, one step at a time, so a migration that fails partway leaves `user_version` at the last step that fully committed rather than at a state nothing on disk matches. - -Why: `docs/adr/the-schema-version-lives-in-pragma-user-version.md`, `docs/adr/the-report-offset-is-trusted-only-with-a-digest.md`, `docs/adr/believe-the-status-file-and-ship-no-hand-dump.md`. - -## Error handling - -### Philosophy - -- **Fail closed on destructive operations.** `hand teardown` refuses unlanded work. `hand merge` refuses red CI. -- **Fail open on read operations.** `hand status` shows "unknown" if herdr is unreachable, doesn't error. -- **No retries.** If something fails, report the error. The agent decides what to do. Don't loop. -- **No fallback backends.** If herdr is down, say so. Don't silently fall back to tmux. -- **No hooks, no guards, no callbacks.** If the agent does something wrong, the CLI refuses with an error message. The agent reads the error and adjusts. No pretool hooks, no turn-end guards, no continuity checks. - -### Exit codes - -- `0`: success. -- `1`: general error. -- `2`: usage error: wrong argument count, unknown flag, unknown command or subcommand, a required flag left out (`hand hold set --reason`), mutually exclusive or mutually dependent flags (`hand watch --timeout` or `--event` without `--until-event`, `hand hold set --blocked-on` on any kind but `blocked` and its absence on a `blocked` one), an invalid argument or flag value (malformed project URL, unknown project mode, harness or hold kind, unparsable `--poll` duration, a non-positive `--timeout`, an unrecognized `--event` kind). - A value the invocation did not supply is not a usage error: the same malformed value read from a `config/` default is a general error (code `1`). -- `3`: precondition failed, meaning the command refuses because the world is not in the state it requires: unlanded work, red CI, a missing or unmerged PR, a missing brief or report, a task, project or hold that does not exist, an id carrying an open hold (`hand spawn`), a task in the wrong kind or state (already merged, not a completed scout, already claimed by another command), a project name or worktree already taken, a project still referenced by active tasks, a PR that conflicts with one already recorded for a task or belongs to neither the task's project's repo nor its declared upstream (`hand pr`), a PR that `gh pr view` can't confirm exists (`hand pr`), a task branch whose PRs do not resolve to a single usable winner (`hand teardown`), a `no-mistakes`-mode project whose gate is not initialized (`hand spawn`, `hand promote` - see "Gate preflight"), a fleet home that already has a watcher attached (`hand watch`, remedied by `--takeover` - see "One watcher per fleet home"). - Two more apply to every command, since each one resolves a fleet home before it does anything: the working directory has no fleet home at or above it and `HAND_HOME` is unset, or `HAND_HOME` is set to a directory that is not a fleet home. The second refuses rather than falling back to the walk up. - These are signalled to `cmd` as sentinel errors (`cmd/precondition.go`), each carrying only the trailing phrase and wrapped by its caller as ` "" `, so one condition renders as one string wherever it surfaces. -- `4`: no event delivered, only from `hand watch --until-event`: its `--timeout` elapsed, or it was signaled, without a transition. This includes the timeout elapsing anywhere in arming, the herdr reachability probe as well as the per-task probe sweep - the window is over either way, and no one task is at fault. Distinct from `0` because there the exit *is* the event delivery, and from `1` because the watcher itself did not fail (see "Delivering an event to a supervisory agent"). -- `5`: arm-time probe failure, only from `hand watch --until-event`: one named task's herdr pane answered its pre-wait probe with a failure, named on stderr. Distinct from `4` because a specific worker is at fault and can be acted on, and from `0` because nothing was delivered (see "Delivering an event to a supervisory agent"). -- `6`: send undelivered, only from `hand send`: the composer stayed busy for the whole `--wait` bound, so the message never reached the pane. Distinct from `1`, which for `hand send` means the send can never succeed (no such herdr pane, herdr itself erroring) - `6` means the opposite, a transient state a caller can retry, most simply with a longer `--wait`. Not `4` or `5`: those are reserved to `hand watch --until-event`. - -### Error output - -Every failure renders one document on stderr, whatever command produced it: - -``` -error: task "nosuch" not found -kind: precondition -exit: 3 -help[1]: - - Nothing changed: this refuses until the state it names is fixed, then the same command runs again -``` - -`kind` names the exit code above, so a caller branches on a word rather than memorizing which number means what: `general` (1), `usage` (2), `precondition` (3), `no-event` (4), `arm-failed` (5), `send-undelivered` (6). -`error` carries the message the command wrote, quoted whenever it holds a `:`, a quote or a newline, so a multi-line error stays one field rather than becoming lines the reader mistakes for further fields. -Every kind but `general` carries a `help[]` line naming what recovers it; a `usage` one names the command that refused, as in ``Run `hand hold set --help` for the arguments and flags this command accepts``. -`general` is the one code with no recovery that can be stated in advance, so it carries no `help[]` block rather than a line that says nothing. - -The document goes to stderr rather than stdout, which `hand watch` owns as an event stream a supervising agent consumes line by line. -A non-zero exit does not retract what a command already printed: `hand doctor`'s findings block and `hand watch`'s event lines stay on stdout whatever the exit code, so a caller reads the document on stderr for why it failed and stdout for what it found. - -Why: `docs/adr/output-is-toon-by-default-and-json-is-retained.md`, `docs/adr/deliver-names-the-state-instead-of-widening-force.md`. - -## Testing strategy - -`herdr`, `treehouse` and `gh` are faked once, in `internal/faketool`, shared by the unit and end-to-end suites alike. -A test declares the fleet it wants - which pool slots exist, which workspaces are already open, which PRs are on which branch - and the package generates the POSIX-sh script for it. -A new test extends the shared fake rather than writing its own. - -Every fake models the state its own commands leave behind, because **a fake that answers a state-changing command identically before and after that command cannot test anything about the state change.** -Returning a treehouse worktree frees its pool slot for the next `get` while leaving the directory in place; closing a herdr tab does not merely unlist it, every later command naming that tab or its pane answers `*_not_found` on stderr with exit 1; merging a PR moves it to `MERGED` for every later `pr view` and `pr list`. - -`internal/faketool/FIDELITY.md` records what the real tool does for each call the suite depends on - exit code, stream, response shape, and what the call leaves behind - observed by running the real binary, not read off its documentation. -Only calls `hand` makes are recorded; behaviour no test exercises does not belong there. -A fidelity claim that is load-bearing names the test that fails without it. - -The contract tests under `tests/contract` close the loop the other direction: built only under the `contract` tag and skipped where the real binary is absent, each one runs the recorded calls against the real tool in scratch state of its own and asserts the shape `FIDELITY.md` claims. -CI never runs them, since it installs no real `herdr` or `treehouse`. - -### Unit tests - -- Machine state reading/writing, and that every field survives a round trip. -- Schema versioning: an existing version-0 database opening as the baseline, a registered migration applying automatically and only once, a fresh database skipping a migration its `schema` already builds, and a refusal on a database newer than the binary. -- Legacy import: idempotence over repeated runs, a database row winning over a restored file, and a loud refusal on an unparseable one. -- Index rebuild: that deleting `state/index.db` costs neither machine state nor corpus, and that a corrupt index recovers. -- Project registry parsing. -- Harness launch command construction. -- Event classification logic. -- Usage-limit detection and resume: that the harness catalogue recognizes every wording it claims to and reads a reset instant out of each - out of the freshest refusal on screen, not an older one still in scrollback - that an uncatalogued harness declines, and - the behavior that actually matters, not merely that a message is recognized - that a limited worker is steered and released while a worker that stopped for any other reason is never read from, steered, or held. The two ways an attempt yields to an operator are covered with it: a `send:` lock already held spends no attempt, and a hold of another kind on the id is left standing. -- Brief validation. -- Worktree collision detection. - -### Integration tests - -Implemented in `tests/e2e`, which drives the built binary against a scratch home. -herdr, treehouse, and gh are faked as shell scripts on `PATH` and remote clones are redirected to a local repo, so the suite never touches a real session provider or the network. -`TestMain` enforces that once for the whole suite: it replaces `PATH` with a hermetic one carrying only the real binaries the suite genuinely runs, then asserts that neither herdr, treehouse, gh nor the worker harness resolves, so a missing fake fails the run loudly instead of quietly answering from the developer's real tools. -CI therefore installs no real herdr or treehouse. - -- Spawn/teardown cycle, including teardown's refusal on unlanded work. -- Watch event stream with simulated herdr state changes. -- Project add/remove/list/sync cycle. -- Merge with mock gh responses. -- Migration of a pre-sqlite fleet home the suite builds by hand, run twice. -- Promote scout-to-ship cycle. -- Collision guard with concurrent tasks. -- Hold lifecycle: set, every `hand status` surface, surviving the teardown of the task it was set on, the spawn refusal on the reused id, and clear. -- Usage-limit resume end to end: a live `hand watch` finding the refusal on a stopped worker's pane, the `limit` hold and the durable schedule it writes, a restarted watcher steering that pane once its stamp comes due, the release when the pane runs again, and a second worker that stopped without a refusal on screen never being steered at all. - -Why: `docs/adr/one-stateful-fake-per-external-tool.md`. - -## Distribution - -### Install methods - -**Pre-built binary (recommended):** -```sh -# release tarball for this OS/arch, from GitHub Releases -curl -fsSLO https://github.com/atqamz/secondhand/releases/latest/download/hand-linux-amd64.tar.gz -tar xzf hand-linux-amd64.tar.gz -install -m755 hand ~/.local/bin/hand - -# or via Nix -nix profile install github:atqamz/secondhand -# or one-shot: nix shell github:atqamz/secondhand -c hand --version - -# or via Go: builds a binary named secondhand, with no embedded version -go install github.com/atqamz/secondhand@latest -``` - -Releases carry `hand-linux-amd64`, `hand-linux-arm64`, `hand-darwin-amd64` and `hand-darwin-arm64` as `.tar.gz`, alongside `checksums.txt`. -The flake covers `aarch64-darwin`, `aarch64-linux` and `x86_64-linux`; on Intel macOS use a release binary or `go install`. -`go install` names the binary after the module (`secondhand`) and embeds no version, so it reports `dev` and never sees an available update. - -**From source (contributors):** -```sh -git clone https://github.com/atqamz/secondhand -cd secondhand -make build -# optionally: cp hand ~/.local/bin/ -``` - -`make build` rather than `go build -o hand .`: the Makefile is what CONTRIBUTING.md documents and what carries the `VERSION` ldflag. - -The source repo is a development repo. End users install the binary and create fleet homes anywhere. - -### Fleet home creation - -```sh -# create a fleet home anywhere -mkdir ~/fleet && cd ~/fleet -hand init - -# or in one shot -hand init ~/fleet -``` - -`hand init` writes the runtime dirs (`data/`, `state/`, `config/`, `projects/`), creates whichever of the `data/` skeleton files are missing (see "Directory layout"), and creates `state/hand.db` if it is not already there. -It also writes the generated AGENTS.md template and its CLAUDE.md symlink (`internal/agentsmd`'s `generatedBody` is the template). -Other existing files are left unchanged, and an optional target path is accepted. -`hand init` chooses no worker default. Open a supervising session in the home and answer the questions -its opening document asks (see "`hand config`"). - -### Self-update: `hand update` - -``` -hand update -hand update --check -``` - -Flags: -- `--check`: print whether an update is available without installing it. - -Behavior: -1. Query GitHub Releases API for the latest version tag. -2. Compare against the running binary's embedded version. -3. If newer: download the binary for the current OS/arch, verify checksum, replace the running binary in place. -4. After update, refresh the generated AGENTS.md template in the resolved fleet home to `internal/agentsmd`'s current `generatedBody`, preserving user edits outside the `hand:generated` markers, and seed whichever `data/` skeleton files that home is missing, on the same absent-only terms as `hand init` (see "Directory layout"). Outside any fleet home both are skipped silently; a `HAND_HOME` that names no fleet home is a warning, not a silent skip. Seeding creates the runtime directories first, exactly as `hand init` does, since a home resolves as one on its `state/hand.db` marker alone. A refresh or a seed that fails is likewise a warning on stderr, not a failed update, since the binary is already replaced. The seed warning names every file that could not be written, in the layout's own order. -5. Refresh the session hook too, on the same warning-not-error terms: an install that moved leaves the hook pointing at a path with no binary behind it any more (see "Ambient context"). -6. Emit old version, new version, whether the binary was replaced, what became of the AGENTS.md template and the session hook, and what changed (from the installed release's notes). - -Every run emits the same seven fields whatever happened: - -``` -hand update -current: v0.3.1 -latest: v0.4.0 -update_available: true -updated: true -agents_md: refreshed -session_hook: refreshed -notes[1]: - - fix: teardown no longer strands worktrees -help[1]: - - Run `hand doctor` to check this home's AGENTS.md against the template v0.4.0 installed -``` - -`agents_md` and `session_hook` are each one of `refreshed`, `unchanged`, `no-fleet-home`, `failed`, or `not-applicable` when nothing was installed. -They are reported separately because they fail separately. -The stderr warnings of steps 4 and 5 stay on stderr rather than becoming fields. - -`--check`, and a `hand update` that finds nothing newer, emit the same document with `updated: false`, both outcome fields `not-applicable` and `notes[0]:`. -An available update adds a `help[]` line naming `hand update`; up to date adds none: - -``` -hand update --check -current: v0.3.1 -latest: v0.4.0 -update_available: true -updated: false -agents_md: not-applicable -session_hook: not-applicable -notes[0]: -help[1]: - - Run `hand update` to install v0.4.0, which also refreshes this home's AGENTS.md template -``` - -**Version check on startup:** `hand` prints a one-line notice to stderr when a newer version is available (checked at most once per day, cached in `state/.version-check`). Non-blocking, non-fatal. - -``` -A new version of hand is available: v0.3.1 -> v0.4.0 -Run "hand update" to update -``` - -### Release pipeline - -Automated via [release-please](https://github.com/googleapis/release-please). - -**How it works:** -1. Commits to `main` use [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `chore:`, etc.). -2. release-please maintains a release PR that accumulates changes and bumps the version according to semver. -3. Merging the release PR creates a GitHub Release with the tag. -4. The release triggers the build workflow that compiles cross-platform binaries and uploads them as release assets. - -**Build matrix:** -- linux/amd64, linux/arm64, darwin/amd64, darwin/arm64. -- Each binary checksummed (SHA256). -- Version embedded at build time via `-ldflags "-X main.version=v0.4.0"`. - -**Release artifacts per tag:** -- `hand-linux-amd64.tar.gz` -- `hand-linux-arm64.tar.gz` -- `hand-darwin-amd64.tar.gz` -- `hand-darwin-arm64.tar.gz` -- `checksums.txt` -- Auto-generated changelog from conventional commits. - -**Workflow files:** - -**`.github/workflows/ci.yaml`:** the tracked file is authoritative - runs on every PR to main and on push to main: lint, test across the OS matrix, e2e gated on test passing, and nix-build, which builds the flake package (`nix build .#default`) on x86_64-linux and runs the built binary's `--version`. - -**`.github/workflows/release.yaml`:** the tracked file is authoritative - runs on push to main; `workflow_dispatch` exists to re-run release-please after a conflicted release PR is rebased. - -**`.github/workflows/triage-label.yaml`:** the tracked file is authoritative - runs on `issues: opened`, labels the issue `needs-triage` unless its author (login, case-insensitive) is `atqamz`. Scoped to `issues: write` only, uses the built-in `GITHUB_TOKEN` via `gh issue edit`, no third-party action. Does not fire on reopen and does not distinguish bot authors from human ones. - -`.github/dependabot.yaml` - keep Go modules and GitHub Actions up to date: -```yaml -version: 2 -updates: - - package-ecosystem: gomod - directory: / - schedule: - interval: weekly - commit-message: - prefix: "chore(deps)" - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly - commit-message: - prefix: "chore(ci)" -``` - -### Repo scaffolding - -Files tracked in the source repo (not generated by `hand init`): - -**`release-please-config.json`:** the tracked file is authoritative - Go release type, 0.x versioning where a breaking change bumps minor not major, and `flake.nix` listed in `extra-files` so its `x-release-please-version`-marked version stays in sync with each release. - -**`.release-please-manifest.json`:** the tracked file is authoritative - release-please owns this file and rewrites the version on every release, so its current value is expected to differ from any snapshot of it. - -**`Makefile`:** the tracked file is authoritative - mirrors the CI workflow's format/vet/lint/test/e2e steps for local use before pushing. - -**`.golangci.yaml`:** the tracked file is authoritative - it keeps golangci-lint's default linter set and only sets `run.build-tags: [e2e, contract]`, without which the tagged packages in `tests/e2e` and `tests/contract` are invisible to the linter. - -**`tools/commentlint/`:** the tracked source is authoritative - a `go run ./tools/commentlint .` target that `make lint` and the CI workflow both invoke over the whole tree, exiting 1 with one `file:line:column` per violation. -CONTRIBUTING.md's "Comments" section owns the two rules it checks, their exemptions, and why they are the only two that are machine-checkable. - -**`.gitignore`:** the tracked file is authoritative - the built binary, the `hand init` runtime directories, Go and Nix build output, worktree tooling files, and editor/OS cruft. - -**`flake.nix`:** the tracked file is authoritative - a `packages.default` derivation building the `hand` binary and a `devShells.default` carrying the Go toolchain. - -**License:** MIT. - -No CD beyond the release - `hand update` is the distribution channel, not a deploy pipeline. - -Why: `docs/adr/the-comment-rule-is-two-mechanical-checks.md`. diff --git a/cmd/launch.go b/cmd/launch.go index 6102727..2d10940 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -87,7 +87,7 @@ func confirmLaunch(client *herdr.Client, paneID, harnessName string) error { return fmt.Errorf("worker is waiting on the %s prompt: %s", prompt.Name, prompt.Refuse) // Pane text has exactly one job here, spotting dialogs. Matching it is safe because claude erases // an answered first-run dialog in place rather than leaving it behind in scrollback, measured - // against a real spawned worker pane (see SPECS.md). + // against a real spawned worker pane. case known: if !answered[prompt.Name] { if err := answerFirstRunPrompt(client, paneID, prompt); err != nil { diff --git a/cmd/notify.go b/cmd/notify.go index 43d45bc..54ea2be 100644 --- a/cmd/notify.go +++ b/cmd/notify.go @@ -22,9 +22,8 @@ func newNotifyCmd() *cobra.Command { return asPrecondition(err) } - // config/notify absent/empty and a failed send are both exit 1, never - // the old exit-0 "notified" line - see SPECS.md's hand notify "Errors" - // for why. + // Absence and send failure both mean nothing reached the only channel, + // so neither may retain the old exit-0 delivery claim. if err := notify.Send(home, message); err != nil { if errors.Is(err, notify.ErrNotConfigured) { return fmt.Errorf("config/notify not set up, nothing delivered: %s", message) diff --git a/cmd/precondition.go b/cmd/precondition.go index 83cfa21..424663b 100644 --- a/cmd/precondition.go +++ b/cmd/precondition.go @@ -8,9 +8,8 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// Errors from internal/state, internal/project, and internal/home that SPECS.md classifies as precondition -// failures (exit code 3) rather than general errors (exit code 1). Those packages are imported by cmd, so -// they cannot construct ExitError themselves and signal via these sentinels instead. +// These package errors are precondition failures rather than general errors. The imported packages +// cannot construct cmd.ExitError themselves, so they signal through sentinels. var preconditionSentinels = []error{ state.ErrTaskNotFound, state.ErrTaskActive, diff --git a/cmd/project.go b/cmd/project.go index 56eaebc..03cb2b5 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -525,7 +525,7 @@ func skippedSync(name, detail string) (syncOutcome, error) { } // Fetches and, when eligible, fast-forwards a single project clone. Never errors on a benign skip (dirty, -// wrong branch, diverged, no remote) - those come back as a skipped outcome, per SPECS.md's fail-open policy. +// wrong branch, diverged, no remote); those return a skipped outcome. func syncOneProject(home string, p project.Project) (syncOutcome, error) { clonePath := filepath.Join(home, "projects", p.Name) diff --git a/cmd/root.go b/cmd/root.go index 4d29bdf..4346db0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -183,8 +183,7 @@ func renderError(w io.Writer, err error, code int, path string) error { return doc.Render(w) } -// The vocabulary is SPECS.md's "Exit codes" table, so a caller can branch on a -// name instead of memorizing which number means what. +// These names let callers branch without memorizing exit numbers. var errorKinds = map[int]string{ 1: "general", 2: "usage", @@ -217,8 +216,8 @@ func errorHelp(code int, path string) []string { return nil } -// ExitError carries a non-default exit code SPECS.md's "Exit codes" table defines, distinct from the -// general-error code (1) cobra otherwise produces for any RunE error. +// ExitError carries a non-default exit code, distinct from the general-error code +// cobra otherwise produces for a RunE error. type ExitError struct { Err error // 2 for a usage error (bad arg count, unknown flag, unknown subcommand, invalid argument or flag diff --git a/cmd/status.go b/cmd/status.go index 73f4c93..e9699eb 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -99,8 +99,7 @@ func reportSummary(id string, lines []state.ReportLine, readErr error, unacked, return truncateReportLine(line, reportSummaryBudget, id) + suffix } -// Degrades gracefully to "unknown" when herdr is unreachable or the pane cannot be queried, per -// SPECS.md's fail-open policy for read operations. +// Read-only status degrades to "unknown" when herdr or the pane cannot be queried. func paneAgentStatus(client *herdr.Client, paneID string) string { if paneID == "" { return string(herdr.StatusUnknown) diff --git a/docs/adr/README.md b/docs/adr/README.md index b0650e3..46b9e8d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -1,55 +1,23 @@ # Architecture decision records -`SPECS.md` is the contract: what a caller can depend on, and what a caller can be wrong about. -This directory holds the reasoning that produced it. +This directory keeps rationale for durable architectural boundaries that are easy to reverse accidentally. -Every file here answers one question `SPECS.md` deliberately does not: why is the contract shaped this way, and what was rejected to get there. -A worker who wants to change a contract clause reads the ADR behind it first, because the alternative it names is usually the change they were about to make. +Behavioral contracts belong with their implementation, command help, and focused tests. External-tool observations belong in `internal/faketool/FIDELITY.md`. User and contributor workflows belong in README.md, AGENTS.md, or CONTRIBUTING.md. Issues and pull requests retain incident history. -## When to write one +## When a record belongs here -The bar is that a future worker might undo the decision by accident. +Keep an ADR only when all three are true: -A decision nobody could reasonably disagree with does not get an ADR. -A decision whose obvious-looking simplification is the bug it exists to prevent does. +1. It describes a stable architectural boundary rather than current implementation detail. +2. It rejects a realistic alternative a future contributor might reintroduce. +3. Its consequences are not already expressed adequately by local code and tests. -If the reasoning is only "this is what the contract says", it is contract and belongs in `SPECS.md`. -If the reasoning is only "this is how the code happens to be written", it belongs in a code comment or nowhere. +Each record uses one file with a present-tense title, date, status, relevant issues and pull requests, then Context, Decision, Rejected alternatives, and Consequences. Link directly to the owning code or tests where that helps navigation without restating their contract. -## Format +`none` means no reference exists. `none single` means the decision accumulated across changes and no one reference represents it. -Each record is one file, `docs/adr/.md`, with this shape: +Do not number records or maintain a duplicate index. Filenames are descriptive slugs. -```markdown -# +## Later changes -- Date: -- Status: accepted | superseded by .md -- Issues: -- PRs: - -## Context -## Decision -## Rejected alternatives -## Consequences -``` - -`none single` is not `none`. -It says the decision accreted across more work than one reference carries, so naming one would credit that one wrongly; `none` says no issue or PR exists. - -There is no template to install, no numbering scheme, and no index file that has to be kept correct. -The filename is the slug alone: it is an identifier a `SPECS.md` clause links to and a reader recognizes, and a date prefix on it would be metadata the linker has to remember and the reader has to ignore. -The date lives inside the file, where it belongs to the record rather than to the link. - -## An ADR is never edited to match a later change - -A record states what was decided on its date, and stays that way even after the decision is reversed. -Reversing one means writing a new record that states the new decision and naming the old one in its context; the old record's `Status` becomes `superseded by .md`, and nothing else in it changes. - -Correcting a typo or a broken link is fine. -Rewriting the reasoning is not, because a record that tracks the current design is just the current design written twice, and it loses the only thing it was for. - -## How `SPECS.md` points here - -A `SPECS.md` section whose shape is not self-evident carries one `Why:` line at its end naming the records behind it. -One line per section, never a link per clause: the contract stays readable as a contract, and the reasoning is one hop away rather than woven back through it. +An accepted record describes the decision made on its date. Reversing the boundary requires a new record that names the earlier one; update the earlier status to point to its replacement without rewriting its historical reasoning. Typo and broken-link fixes are fine. diff --git a/docs/adr/a-fork-projects-upstream-is-declared-never-inferred.md b/docs/adr/a-fork-projects-upstream-is-declared-never-inferred.md deleted file mode 100644 index 52b3650..0000000 --- a/docs/adr/a-fork-projects-upstream-is-declared-never-inferred.md +++ /dev/null @@ -1,48 +0,0 @@ -# A fork project's upstream is declared by a command of its own, never inferred - -- Date: 2026-08-05 -- Status: accepted -- Issues: atqamz/secondhand#134 -- PRs: atqamz/secondhand#135, atqamz/secondhand#142 - -## Context - -`hand` pushes a worker's branch to the repo it cloned. -A fork contribution's PR does not live there: it lives on the repo the work is offered to, which is the repo `hand pr` and gate-opened-PR detection have to look at. - -Both compared against the clone's `origin` remote alone, so a genuine upstream PR was refused as belonging to a foreign repo, and teardown's landed-work check read landed work as unlanded. - -Widening either comparison is easy and is the whole risk here. -The guard's only job is refusing a PR that belongs to somebody else's repo, so whatever tells `hand` about an upstream decides how narrow the guard stays. - -## Decision - -A project carries an optional `upstream` slug, and only an operator's declaration puts one there: `hand project upstream `, cleared by passing an empty repo. -A project that declares nothing is guarded exactly as it was before this existed. - -It is a command of its own rather than a flag on `hand project add`, because a fork project is normally already registered by the time the first upstream contribution comes up, and `hand project add` clones - it cannot be re-run against a project that already exists. - -What the declared slug then does to PR matching - searched alongside the project's own repo, head refs restricted to the project's repo, every comparison case-folded - is in `an-unrecorded-pr-is-recovered-by-head-ref.md`. - -## Rejected alternatives - -**`hand project add --upstream `, with no separate command.** -It looks like the smaller surface and is the change a future worker is most likely to make. -It serves only a project registered after somebody already knew an upstream contribution was coming, and the recovery for every other project is `hand project remove` plus `hand project add`, which re-clones a working repo to record one string. - -**Infer the upstream from GitHub's fork parent.** -It removes the command and makes what the guard accepts depend on what GitHub answers at that moment rather than on what an operator declared. -A fork of a fork, a renamed parent, or an unreachable API each move the guard without anybody deciding to. - -**Accept any PR whose repo looks related to the project's own.** -There is no resemblance test that admits an upstream and refuses a stranger's repo of the same name. -The narrow version of this is the head-repo filter in `an-unrecorded-pr-is-recovered-by-head-ref.md`, which works precisely because it asks about a branch `hand` pushed rather than about a repo name. - -## Consequences - -The slug is projected into `data/projects.md`, whose fields are whitespace-separated, so one containing whitespace is refused at declaration time rather than read back truncated later. - -An operator who forgets the declaration sees a refusal that names it - the declared upstream, or that none is declared - because "wrong upstream" and "no upstream" are different mistakes with different fixes. - -Nothing reconciles the declaration with reality afterwards. -A project whose upstream is stale carries a wrong slug until somebody re-declares it, which is the cost of the guard depending on a statement rather than on a lookup. diff --git a/docs/adr/a-silent-report-channel-is-parked-on-three-bounds.md b/docs/adr/a-silent-report-channel-is-parked-on-three-bounds.md deleted file mode 100644 index 84f9c3f..0000000 --- a/docs/adr/a-silent-report-channel-is-parked-on-three-bounds.md +++ /dev/null @@ -1,70 +0,0 @@ -# A silent report channel is its own trigger, bounded by three tiers - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#127 -- PRs: none single - -## Context - -`stale` watches herdr transitions. -A pane that registers no transition at all gives it nothing to fire on, and a worker can sit healthy and quiet indefinitely without one. -That is the shape of a wedged worker: the pane is alive, herdr has nothing new to say, and the report channel stopped growing. - -So the channel's own silence has to be a trigger of its own. -The question is what bound to measure it against, since the same duration is alarming for one worker and expected for another. - -A worker that reported `paused: waiting on the nightly build` has already explained its quiet. -A worker that reported `done` and still holds a pane is finished and unhurried. -A worker that reported `working: refactoring the parser` and then went quiet is the case the trigger exists for. - -## Decision - -`parked` fires on the report channel not growing for longer than its bound, independently of `stale`. -The bound is chosen by the last classified report line, and there are three tiers with a config key each: - -- `paused`: the long bound, `config/parked-paused-bound`, default 3600s. -- `done` and `failed`: their own longer bound, `config/parked-done-bound`, default 5400s. -- everything else, including `working`, `blocked`, `needs-decision` and no report at all: the short bound, `config/parked-other-bound`, default 1200s. - -`done` and `failed` are bounded rather than exempt. -What actually severs a task from steering is the status file being torn down, not the worker's own last word about being finished, so a finished worker still attached to a pane is silence like any other. - -The trigger is edge-triggered like every other one: it fires once per silence episode and refires only once the report file grows past the mtime it fired for. -That instant is persisted as `parked_fired_for` rather than re-derived; see `the-watcher-persists-what-it-announces.md`. - -The event carries the last report line and its age, and nothing else. -A parked worker and a crashed one are indistinguishable from the status file alone, so the process check is left to the caller. - -## Rejected alternatives - -**Extend `stale` to cover it, rather than adding a trigger.** -`stale` is defined over herdr transitions, and the case here is the absence of one. -Folding the two together means one threshold answering two questions, and the answer is wrong for whichever question it was not tuned for. - -**One bound for every state.** -Tuned short it wakes an operator about a `paused` worker that said what it was waiting for, and about a `done` worker whose pane is simply still open. -Tuned long it is silent for the twenty minutes that matter on a `working` worker. - -**Two tiers, reusing the `paused` bound for `done` and `failed`.** -It is the tempting simplification, and it makes the most expected silence in the fleet - a finished worker nobody has torn down yet - share a threshold with a worker that is actively waiting on something. -Three keys cost one config file each and let the expected case be quieter than the explained one. - -**Exempt `done` and `failed` entirely.** -A `done` report is a claim, not a fact, and a worker that wrongly believes it finished is exactly the worker whose silence needs surfacing. -See `the-report-channel-is-the-only-outcome-signal.md`. - -**Derive the latch from the file's current mtime on each tick instead of persisting it.** -A done task's report file never grows again, so every restart re-fires against the same frozen instant and evicts real history from the capped `state/events.log` (atqamz/secondhand#127). - -**Have the event report whether the worker's process is alive.** -The status file cannot tell a parked worker from a crashed one, and a check that guesses would make the event's own claim the unreliable part. -`hand status ` and the pane itself answer it on demand. - -## Consequences - -Three config keys exist that an operator can set independently, and a fleet that wants one bound sets all three to the same value. - -A worker whose report channel is growing is never parked, however long it has been busy, which is deliberate: activity is the signal, not progress. - -Adding a report state means deciding its tier, and the short bound is the safe default for anything unexplained. diff --git a/docs/adr/a-steer-that-never-lands-leaves-a-durable-trace.md b/docs/adr/a-steer-that-never-lands-leaves-a-durable-trace.md deleted file mode 100644 index 49691fe..0000000 --- a/docs/adr/a-steer-that-never-lands-leaves-a-durable-trace.md +++ /dev/null @@ -1,73 +0,0 @@ -# A busy composer is waited out, and a steer that never lands leaves a durable trace - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#102 -- PRs: none single - -## Context - -`hand send` types a message into a running worker's pane. -The pane is busy whenever the agent is mid-response, which is most of the time a supervisor has something to say. - -The first implementation treated a busy composer as an error and returned immediately. -Every caller then wrote the same retry loop in shell, and the loops were all slightly different. -Worse, two of them racing the same pane lost a steer outright: both saw the composer free, both typed, and one message ended up interleaved into the other. - -There is also a failure that looks like success. -The text goes into the composer and the submit keystroke fails, so the message is sitting in the pane unsent, and the process that put it there exits. -Nothing anywhere records that a steer was attempted. - -## Decision - -A busy composer is the normal arrival state, not an error. -`hand send` waits for it, bounded by `--wait` (default `config/send-wait`, else `2m`), because an unbounded wait is a hang and a zero wait is the shell loop coming back. - -A per-task `send:` lock serializes senders, so a second `hand send` waits behind the first instead of racing it on the same pane. -`hand watch`'s usage-limit resume takes that lock without waiting, since it has a whole tick to try again and must not stall behind a long steer. - -Whenever the message does not demonstrably land - the wait elapses, the text fails to send, or the submit keystroke fails after the text went in - the message and a timestamp are written to the task row. -A steer that never arrived is a thing the operator has to know about, and the process that attempted it is gone. -The three cases are recorded identically because they are the same fact about the world: a steer with no evidence it landed. - -They exit differently, though. -The elapsed wait is exit 6 and its own code, because it is transient and a caller can retry with a longer `--wait`. -The two delivery failures are ordinary exit 1. - -The trace is cleared by any later send that reaches the pane, whatever message it carried, since a delivered steer moots an abandoned one. -Failing to clear it warns and still succeeds: the message is already in the pane, and failing there invites a retry that double-sends. - -The row lock for the trace is separate from the send lock and short-lived. -A `hand send` waiting out two minutes must not block a `hand status` read or a watcher tick on the same task. - -## Rejected alternatives - -**Keep failing fast on a busy composer.** -It moves the retry into every caller, and unsynchronized retries against one pane were what lost a steer in the first place. - -**Wait indefinitely.** -An invocation that never returns is indistinguishable from a hung one, and there is no upper bound on an agent turn. - -**Hold the task-row lock for the whole wait, so the trace write needs no second lock.** -A two-minute wait would then block every reader of that task, including the watcher. -The lock is only needed for the write. - -**Keep the undelivered message in memory and print it on failure.** -Printing puts it in a transcript nobody re-reads. -The operator finds out about a lost steer from `hand status`, so the trace has to be in the store. - -**Give each failure mode its own exit code.** -Only the retryable one changes what a caller does next. -A distinct code for "the submit keystroke failed" is a code nobody branches on. - -**Clear the trace only when the same message is re-sent successfully.** -It keeps a stale trace alive after the operator has said something better, and comparing message text to decide whether a concern is resolved is a guess about intent. - -## Consequences - -`hand send` is slow by default, and that is the intended trade: the caller's alternative was a loop that was slower and raced. - -The undelivered trace is a second place a message lives, so it has to be cleared on the success path. -Forgetting that leaves a permanent warning on a healthy task, which is why the clear failure warns instead of failing. - -`--file` exists for the same reason the wait does: a multi-paragraph steer through shell quoting is a correctness problem the caller should not have to solve. diff --git a/docs/adr/ambient-context-is-a-session-hook-not-a-file.md b/docs/adr/ambient-context-is-a-session-hook-not-a-file.md index 1d7468d..5671955 100644 --- a/docs/adr/ambient-context-is-a-session-hook-not-a-file.md +++ b/docs/adr/ambient-context-is-a-session-hook-not-a-file.md @@ -1,4 +1,4 @@ -# Ambient fleet context is a `SessionStart` hook, not a rendered file +# Ambient fleet context is a session hook, not a rendered file - Date: 2026-08-04 - Status: accepted @@ -7,57 +7,20 @@ ## Context -A supervising agent that has to ask for the fleet before it can reason about it spends a turn on what the session could have opened with. - -`data/dashboard.md` was the file-based answer. -`hand` rendered the fleet into it, the agent read it as part of its context, and the file was there whether or not it was current. -It was removed with atqamz/secondhand#62, and the accuracy defects it produced are the same family as atqamz/secondhand#53: a rendering read back in as evidence. - -The replacement has to arrive at the start of a session without anyone asking for it, which means something outside `hand` has to run `hand`. +A supervisor needs current fleet state when a session starts. A rendered dashboard can look current while silently lagging the store, and putting live state in AGENTS.md gives a generated rules file the same defect. ## Decision -`hand init` and `hand update` install `hand` as a Claude Code `SessionStart` hook in the fleet home's `.claude/settings.json`, so every conversation opens with the bare command's overview already in context: identity, home, counts, and the task table. - -The output is generated at the moment it is read, which no file can be. +Fleet context is produced by running the bare `hand` command from a `SessionStart` hook. The hook is merged into the operator's settings rather than replacing them, and is installed only for a fleet home. -`settings.json` is merged, never overwritten. -An operator's permissions, other events and other `SessionStart` entries are carried through untouched, and a file `hand` cannot parse is an error rather than a clobber. - -`hand` owns at most one entry: the first whose command runs this binary or any binary named `hand`. -Refreshing repoints that entry's path and leaves any arguments the operator added alone. - -Installing is confined to a fleet home. -A directory with no `state/hand.db` gets no `.claude/` directory at all. +The implementation and merge behavior live in [`internal/sessionhook`](../../internal/sessionhook) and its tests. The generated workflow that explains the snapshot lives in [`internal/agentsmd`](../../internal/agentsmd). ## Rejected alternatives -**Keep `data/dashboard.md` and re-render it more often.** -More often is still not "at the moment it is read", and the failure is silent: a stale dashboard looks exactly like a current one. -It is also durable state derived from a rendering, which nothing in `hand` does any more. - -**Put the overview in `AGENTS.md`, which the agent reads anyway.** -`AGENTS.md` is a generated template refreshed by `hand update`, so the same staleness applies, and the fleet state would be interleaved with the operating rules it is meant to be read against. - -**Rely on the agent running `hand` first, per a rule in `AGENTS.md`.** -That is the "remember to check" pattern, and it costs a turn every time it works. - -**Overwrite `settings.json` on install, since `hand` owns the fleet home.** -It does not own the operator's permissions or their other hooks. -Clobbering them is a data loss whose blast radius is outside `hand` entirely. - -**Own every entry whose command mentions `hand`.** -Then an operator's own wrapper script named differently but invoking `hand` is either adopted or duplicated. -Matching this binary or a binary named `hand`, first match only, is the narrowest rule that still finds the entry after an install has moved. - -**Install into any directory, so a checkout of the tool gets the hook too.** -A directory with no `state/hand.db` runs no supervising session, so the hook would fire `hand` where there is no fleet to report. +- A rendered dashboard duplicates live state and can be stale without saying so. +- Embedding the overview in AGENTS.md mixes perishable state with operating rules. +- Requiring the supervisor to remember an initial status command spends a turn and fails silently when forgotten. ## Consequences -The mechanism is Claude Code specific. -A supervisory harness with no session-start hook gets no ambient context and has to run `hand` itself, and nothing in `hand` papers over that. - -`.claude/settings.json` in a fleet home is a file two parties write, so every `hand update` is a merge that has to survive whatever the operator did since. - -`hand` with no arguments is now load-bearing as the session opener rather than only as a convenience, so its output shape is contract. +Harnesses without a session-start mechanism must run `hand` themselves. The settings file remains shared with the operator, so every refresh must preserve entries `hand` does not own. diff --git a/docs/adr/an-unrecorded-pr-is-recovered-by-head-ref.md b/docs/adr/an-unrecorded-pr-is-recovered-by-head-ref.md deleted file mode 100644 index 9352cdd..0000000 --- a/docs/adr/an-unrecorded-pr-is-recovered-by-head-ref.md +++ /dev/null @@ -1,70 +0,0 @@ -# An unrecorded PR is recovered by head ref alone, and ambiguity refuses - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#134, atqamz/secondhand#146 -- PRs: none single - -## Context - -`hand pr` records a PR under a task. -A no-mistakes gate's own `pr` step opens a PR directly, so genuinely landed work routinely has no PR recorded against it, and a task whose PR is unrecorded reads as unshipped everywhere downstream: teardown refuses it, the merge poll has nothing to watch, and the gate check has no PR to ask about. - -So `hand status` has to recover the PR from GitHub. -The recovery needs a key, and the only fact `hand` holds that GitHub also holds is the task's branch. - -## Decision - -The lookup matches on head ref alone, never on title, issue number or task id. -A title is prose a worker wrote and an issue number is a claim in a PR body: both are guesses about intent, and a wrong recovery records somebody else's PR as this task's work. -A head ref is what the branch is. - -Matching on a branch name is not by itself unique, so three narrowings apply. - -A declared `upstream` is searched as well as the project's repo, because a fork contribution's PR is opened on the upstream while the branch is pushed to the fork. -Only PRs whose head branch lives in the project's own repo count, because an upstream carries head refs from every contributor's fork and a stranger's same-named branch would otherwise be recorded here. -Every repo-slug comparison folds case, because a GitHub slug is unique only up to casing: `gh` reports GitHub's canonical casing while the clone's `origin` remote carries whatever the operator typed. -Compared exactly, the head-repo filter drops a landed PR, and an `upstream` naming the project's own repo in another casing is searched twice, which returns the one real PR as its own same-tier duplicate and makes it ambiguous (atqamz/secondhand#146). - -Several PRs on one branch resolve by preference tier - merged, then open, then closed-unmerged - and only when the winning tier holds exactly one. -A merged PR coexisting with an open one on the same head ref refuses rather than resolving to the merged one, because an open PR is live evidence the branch may carry unlanded work. -Matches from both repos go through that one tier pass, so a fork whose upstream also carries a PR on that branch is ambiguous exactly like two PRs in one repo. - -The whole lookup is best-effort and non-blocking, and it runs only in the single-task view. - -## Rejected alternatives - -**Match on the issue number in the PR body, or on the PR title.** -Both are authored text. -A worker who pasted the wrong number, or two PRs whose titles both name the same fix, produce a confident wrong answer, and the wrong answer is durable: it gets recorded. - -**Resolve ambiguity by picking the newest PR, or the merged one.** -Picking the merged one over an open one is the case most likely to be wrong, because the open PR is the evidence that the branch is still moving. -Refusing costs one `hand pr` invocation; guessing wrong records a PR nobody will re-check. - -**Normalize casing by rewriting the stored slug at registration time.** -It fixes projects registered afterwards and leaves every already-registered project broken, and the comparison still has to be correct for a slug that arrived from `gh`. -Fold at comparison, once, where both sides meet. - -**Search the upstream without the head-repo filter.** -A popular upstream has head refs from every fork, so a common branch name recovers a stranger's PR. -That is worse than recovering nothing. - -**Do the lookup in the fleet overview too.** -It is one `gh` call per unrecorded ship task, so a fleet-wide render pays for every task to answer a question about one. -The single-task view is where somebody is already asking about that task. - -**Record the recovered PR only after asking the operator.** -The recovery exists because nobody noticed the gate had opened the PR. -A confirmation prompt puts the notice back in the path that already failed to happen. - -## Consequences - -A branch reused across tasks is unrecoverable by this route, and that is correct: the key genuinely does not identify one task. -Such a task needs `hand pr` run against it by hand. - -The refusal cases are silent in the sense that nothing is recorded, and `hand status` reports what it read rather than what it declined to conclude. -An operator who expected a PR to appear and sees `none` has to run `hand pr`, and no output tells them ambiguity was the reason. -That is the accepted cost of not blocking a status render on a diagnosis. - -Case-folding is the kind of bug that reappears wherever a slug is compared, so it is a property of every repo-slug comparison in this lookup rather than of the two sites where it was found broken. diff --git a/docs/adr/believe-the-status-file-and-ship-no-hand-dump.md b/docs/adr/believe-the-status-file-and-ship-no-hand-dump.md deleted file mode 100644 index 61cdb86..0000000 --- a/docs/adr/believe-the-status-file-and-ship-no-hand-dump.md +++ /dev/null @@ -1,52 +0,0 @@ -# The status file wins over the database, and there is no `hand dump` - -- Date: 2026-08-04 -- Status: accepted -- Issues: none -- PRs: none - -## Context - -A fleet home holds machine state authoritative in sqlite at `state/hand.db`, and a prose corpus authoritative in files under `data/`. -`state/.status` sits across that line: the worker writes it as prose, and `hand` reads it as the outcome signal. - -So there are two places that can answer "what did this worker say": the file, and the `last_report_state` / `last_report_note` the watcher persisted from it. -They can disagree, and the design has to say which wins before the disagreement happens rather than after. - -The fleet has twice run a stale `hand` binary while every signal that binary produced read healthy. -Both recoveries were `cat` on the status files. - -## Decision - -When the database and a `.status` file disagree about what a worker said, the file wins. - -The database is authoritative for everything the file does not carry: what `hand` recorded, decided or observed, which is most of machine state. -The file is authoritative for what the worker said. -The database never holds a second copy of the file's content as a substitute for it; `last_report_state` is a projection the watcher carries forward, not a rival record. - -There is deliberately no `hand dump` and no other command whose purpose is to print machine state for recovery. - -## Rejected alternatives - -**Make the database authoritative for reports too, since it is authoritative for everything else.** -The choice is not about which store is more reliable, it is about what the two failure modes cost. -A `.status` file is readable by `cat`, `tail -f`, an editor, and a person with no tooling at all. -The database is readable by a working `hand`, which is the thing that was broken both times it mattered. - -**Add `hand dump` so recovery has a first-class path.** -A dump command is one more thing that depends on the binary, so it is no help in the case that actually happens. -It would also read as the recommended recovery route, which would move operators off the one route that survives the failure. - -**Store reports in the database and render the file from it as a convenience.** -Then the file is a rendering, and nothing durable in `hand` is derived from a rendering (atqamz/secondhand#53). -It also inverts the write direction: the worker would need `hand` to report, which is the coupling this avoids. - -## Consequences - -`hand status` re-reads the file for its report suffix and its `unacknowledged` flag rather than answering from the row alone, and both views derive that flag from the whole file rather than the 5-line history window, so the two views cannot disagree. - -The report channel has to stay a plain append-only text file forever. -Compressing it, rotating it, or moving it into the database each break the recovery this depends on. - -sqlite in rollback journal mode with one short-lived process per command is part of the same commitment: a fleet home stays a directory that can be copied, backed up and inspected with ordinary tools. -A daemon or a connection pool would make the home a thing you have to ask a running process about. diff --git a/docs/adr/deliver-names-the-state-instead-of-widening-force.md b/docs/adr/deliver-names-the-state-instead-of-widening-force.md deleted file mode 100644 index ae6e3df..0000000 --- a/docs/adr/deliver-names-the-state-instead-of-widening-force.md +++ /dev/null @@ -1,61 +0,0 @@ -# A missing terminal state gets its own name rather than a wider `--force` - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#69, atqamz/secondhand#78, atqamz/secondhand#129 -- PRs: atqamz/secondhand#135 - -## Context - -`hand teardown` is fail-closed: it refuses to release a worktree and a pane while the work is not landed, because a teardown is what makes the work unrecoverable. - -The guard asks one question, "is this landed", and answers it from `merged` and a recorded PR. -Real terminal states exist that the question cannot express. -A contribution offered to a repo the fleet does not control - a fork PR on an upstream - lands when a maintainer decides, possibly never. -A scout task's deliverable is a report, so there is nothing to land at all. -A PR opened by the gate's own `pr` step against a project the fleet does not own is the same shape (atqamz/secondhand#69). - -`--force` is already there and it would work. -That is the trap: forcing records the task as `torn-down`, which is indistinguishable from work abandoned unlanded. -The record would then be wrong about the only thing anybody reads it for later. - -## Decision - -When the guard cannot express a terminal state, the state gets a name. - -`hand deliver --reason ` records that the work is handed off and the decision to land it belongs to someone outside the fleet. -`--reason` is required, because the record has to say what was delivered and who decides, not merely that something was. - -It writes `delivered_at` and `delivered_reason` and nothing else. -It never sets `merged` or `pr_merged_observed`, which both assert the work landed. - -The state is keyed off the recorded delivery, never off `kind`, so a task filed as a ship whose deliverable turned out to be a report tears down cleanly without anyone correcting the kind first (atqamz/secondhand#129). - -Re-running with a new reason is a correction rather than a conflict, unlike `hand pr`'s one-task-one-PR rule, because nothing consumes the mark until teardown reads it. - -## Rejected alternatives - -**Widen `--force` to cover the case, or add `--force-delivered`.** -Forcing records `torn-down`, so the fleet's record of a contribution offered upstream becomes identical to its record of abandoned work. -A flag that changes what the record *means* is a state, and giving it a flag name hides that. - -**Relax the guard to accept any task whose kind is `scout`.** -The kind is what somebody filed the task as, and it is routinely wrong by the time the work is done. -Keying on the recorded delivery means the correction is one command rather than a kind edit plus a teardown. - -**Treat an upstream PR as merged once it is open.** -`merged` asserts the work landed. -A maintainer who closes the PR unmerged leaves the fleet claiming otherwise forever. - -**Make `--reason` optional and default it.** -The whole value of the record is what it says. -A defaulted reason is `torn-down` with extra steps. - -## Consequences - -There is now a general pattern to follow rather than a one-off: when a fail-closed guard refuses a legitimate state, name the state. -Widening the guard is the reflex this exists to displace. - -`hand status` surfaces the state in three places - a `delivered` token in the fleet view's `flags`, a `delivered` field in the single-task view, and `delivered_at` / `delivered_reason` in `--json` - because a state nothing renders is a column. - -Every future guard-blocking state is a candidate for the same treatment, and each one is a new command rather than a new flag, which is the deliberate cost. diff --git a/docs/adr/gate-checks-read-no-mistakes-output-not-its-database.md b/docs/adr/gate-checks-read-no-mistakes-output-not-its-database.md index 3dddfb5..0588d00 100644 --- a/docs/adr/gate-checks-read-no-mistakes-output-not-its-database.md +++ b/docs/adr/gate-checks-read-no-mistakes-output-not-its-database.md @@ -1,4 +1,4 @@ -# Gate checks read `no-mistakes`'s own output, never its database +# Gate checks read no-mistakes output, not its database - Date: 2026-08-04 - Status: accepted @@ -7,69 +7,20 @@ ## Context -A project registered with `--mode no-mistakes` expects its work to go through that gate. -`hand` never drives the gate: it does not call `axi run`, `axi respond` or `axi abort`. -The worker does. -So `hand`'s only stake is answering two questions before and after the fact. - -Is the gate initialized for this repo? -`no-mistakes` keys its own state on the absolute `working_path` of the repo it was initialized against, and two ordinary histories orphan that row with nothing obliging anyone to notice: the fleet home gets renamed, moving every clone path at once, or a project is registered and `no-mistakes init` is never run. -Both leave a gated project silently ungated. - -Did this shipped PR actually go through a run? -A project's gate can be ready and still never have run against the branch a PR came from, because the project was registered after the fact, the PR was opened by hand outside the `pr` step, or the check was bypassed. - -Both answers are available two ways: parse what `no-mistakes` prints, or read `~/.no-mistakes/state.sqlite`. +Secondhand needs to know whether a repository can use its configured gate and whether a PR appears in a completed gate run. Both answers exist in no-mistakes output and in its private sqlite schema. ## Decision -Both checks read `no-mistakes`'s own output text and never its database. -`GateStatus` runs `no-mistakes status` in the project's clone; `GateRunPRs` runs `no-mistakes runs --limit 10000` and collects the PR URL each `completed` row recorded for itself. - -Every outcome is read from that text, including the ones that look like exit-code cases. -`no-mistakes status` always exits 0, initialized or not, and reports both orphaning histories with identical text, so the preflight does not try to tell them apart. +Secondhand invokes no-mistakes and interprets its public output. It never reads `~/.no-mistakes/state.sqlite`. -Failure outcomes are kept distinct rather than collapsed, because the remedies differ. -Not initialized is exit 3 naming `no-mistakes init` verbatim, since that command is idempotent and repairs a stale `working_path` in place. -A missing or unrunnable binary, a clone path that does not exist, and a clone path that is not a git repository are each exit 1 with their own message: the world is not in a state the operator fixes by initializing anything. - -A question the check could not answer never renders as the stronger claim. -`unreachable` is its own bucket, distinct from `no run found`, and it covers a missing clone, an unrunnable binary, an uninitialized gate and a non-git path. - -The gated marker says only that the `pr` step opened this exact PR from a run that reached `completed`, and the wording is deliberately no stronger. +[`internal/project/gaterun.go`](../../internal/project/gaterun.go) owns the parser and distinctions between answers. Its package tests and the command-level gate tests own the observable behavior. ## Rejected alternatives -**Read `~/.no-mistakes/state.sqlite` directly.** -It is another tool's private schema, with no compatibility promise and no version gate `hand` could check. -It is also more precise than the answer `hand` is entitled to give, which invites claims the data does not support. - -**Trust `no-mistakes status`'s exit code.** -It always exits 0. -An uninitialized repo, a stale `working_path` and a non-git directory are all successes by that measure, and the last one used to read as a ready gate. - -**Collapse every failure into "not initialized" and name one remedy.** -The remedy for a missing binary is not `no-mistakes init`, and telling an operator to run it sends them somewhere the problem is not. - -**Let an uninitialized gate read as an empty run list.** -`no-mistakes` still holds that repo's completed runs, so an empty list would report a genuinely gated PR as never gated. -That is the single worst answer this check can give. - -**Detect the missing clone path from the failed chdir.** -It surfaces as "binary not found or not runnable", which is misleading. -The path is stat-ed before the binary is run at all. - -**Make the gated marker a per-commit or per-branch answer.** -`no-mistakes` keys on `working_path`, not per PR, and `hand` records no head commit to compare against. -A push after the matched run still reads as gated, and the wording admits that rather than implying otherwise. +- Reading another tool's private schema couples releases without a compatibility boundary. +- Trusting process exit alone loses states that no-mistakes reports at exit zero. +- Collapsing every failure into one answer gives the operator remedies unrelated to the actual fault. ## Consequences -Both checks are text scraping against another tool's output, so a wording change in `no-mistakes` breaks them. -That is the accepted cost, and it is why `internal/faketool` records the real output rather than a paraphrase. - -`--skip-gate-check` bypasses the preflight and prints a warning to stderr naming the project, so a bypass is visible in the transcript rather than a silent env var. - -`hand project list` runs the same check per project and carries the outcome in a `gate` column, so a stale gate is visible without waiting for a spawn to refuse. - -`GateRunPRs` is answered per clone and cached for one render, so a fleet with several done ship tasks on one project pays one `no-mistakes` process rather than one per task. +An upstream wording change can break the parser. The shared fake records the output Secondhand depends on, and focused tests keep an unanswered check from becoming a stronger claim. diff --git a/docs/adr/harness-templates-launch-interactively.md b/docs/adr/harness-templates-launch-interactively.md index b582b64..b9e4f23 100644 --- a/docs/adr/harness-templates-launch-interactively.md +++ b/docs/adr/harness-templates-launch-interactively.md @@ -1,59 +1,27 @@ -# Every harness launch template runs the harness interactively, never headless +# Harnesses launch interactively, with liveness owned by herdr - Date: 2026-08-04 - Status: accepted -- Issues: atqamz/secondhand#152 +- Issues: atqamz/secondhand#28, atqamz/secondhand#152 - PRs: none single ## Context -`hand spawn` constructs a launch command from a per-harness template, `cd`s into the worktree, and sends it to a herdr pane. - -Every supported harness has a headless mode that is easier to launch and easier to reason about: `claude --print`, `opencode run`. -It answers once and exits, leaving a clean transcript and no resident process. - -Three things `hand` does afterwards need the process to still be there. -`hand send` writes into a running pane. -`hand watch` polls pane state to classify a worker as working, blocked or idle. -The `no-mistakes` delivery mode drives many turns as the worker responds to review, test, document and lint gates. - -Headless was the original shape and it is what made the interactive first-run dialogs appear, so switching cost something visible and immediately looked like a regression. +Workers must survive multiple steers and gate turns. Interactive launches provide that resident process but expose first-run dialogs. Pane text can outlive a dead process, while herdr can identify a live harness without knowing what dialog blocks it. ## Decision -Every template launches its harness interactively and must stay resident for the whole task. -A one-shot invocation is not an acceptable template for any harness, present or future. - -Each template sets its harness's autonomy or permission flag, so an unattended worker does not stall on a permission prompt. - -The first-run dialogs interactive launch exposes are recognized by signature in `internal/harness` rather than avoided. -They are answered where answering is scoped to the work, and deliberately not answered where it is not: the managed-settings security dialog grants arbitrary code execution and prompt interception for every run on the host, so it is recognized, surfaced, and left for the operator to accept once. +Every harness template launches a resident interactive process with its autonomy flag. Herdr alone answers whether that process is live. Pane text is used only to detect dialogs, and a launch is confirmed only when both signals are clear. -A declared model or effort a harness has no flag for is warned about on stderr rather than dropped in silence (atqamz/secondhand#152). +Exact commands and dialog signatures belong to [`internal/harness`](../../internal/harness); confirmation belongs to [`cmd/launch.go`](../../cmd/launch.go). Their tests own supported flags, prompt handling, and failure behavior. ## Rejected alternatives -**Launch headless and re-invoke per turn.** -There is nothing left to send to, classify, or drive through a gate. -Every one of `hand send`, `hand watch` and the gate mode would need its own mechanism for state a resident process already holds. - -**Launch headless and keep a wrapper process resident in the pane.** -The wrapper would have to reimplement the harness's own session continuity, and pane state would then describe the wrapper rather than the worker, which is the one thing `hand watch` reads it for. - -**Auto-accept every first-run dialog, including the managed-settings one.** -Accepting it is a host-wide grant with nothing to do with the checked-out repository. -`hand` has no standing to make that grant on an operator's behalf, and doing it silently on a spawn is the worst possible moment. - -**Drop a declared model or effort silently when the harness has no flag for it.** -The operator declared it in a brief and would have no way to learn it was ignored. -A warning costs one stderr line and is the difference between a wrong model and a known-wrong model. +- Headless reinvocation has no resident session for `hand send`, watching, or multi-turn gates. +- A wrapper around headless runs would reimplement harness continuity and make pane state describe the wrapper. +- Screen text cannot prove liveness because a dead harness leaves text behind. +- Agent presence alone can confirm a process parked forever on a dialog. ## Consequences -Every spawn is subject to first-run dialogs, and the workspace trust dialog fires on *every* spawn rather than once per host, because each treehouse worktree is a fresh path under the pool root. - -Dialog signatures are matched against another tool's UI text, so they must stay case-sensitive and keep their distinguishing anchors, and they go stale when a harness changes wording. -`internal/harness` is the authority for them; `SPECS.md` describes the policy, not the catalogue. - -Adding a harness means an interactive template, its autonomy flag, its model and effort capability flags, and its first-run signatures if it has any. -A template that cannot satisfy the first requirement does not get added. +Dialog wording is an external compatibility surface and may need maintenance as harnesses change. Unknown dialogs fail toward an unconfirmed launch where the harness has a catalogued prompt surface. diff --git a/docs/adr/herdr-workspace-labels-carry-a-hand-prefix.md b/docs/adr/herdr-workspace-labels-carry-a-hand-prefix.md deleted file mode 100644 index 5d0d5d6..0000000 --- a/docs/adr/herdr-workspace-labels-carry-a-hand-prefix.md +++ /dev/null @@ -1,56 +0,0 @@ -# A project's herdr workspace label carries a `hand:` prefix, and a bare-label workspace is not adopted - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#118 -- PRs: none single - -## Context - -`hand` keeps one herdr workspace per project and finds it by label. -The obvious label is the project name, and that is what shipped. - -Herdr derives a workspace's label from its root directory's basename when none is given. -So the label space is neither unique nor owned by `hand`: any directory on the machine whose basename matches a project name produces a workspace with the identical bare label. -The fleet home itself is one such directory, and so is any other tool's workspace rooted at a same-named path. - -`FindWorkspaceByLabel` returns whichever match `herdr workspace list` happens to return first. -Under a collision that is a silent dispatch of a worker into a workspace `hand` never created. - -## Decision - -The label is `hand:`, mirroring the existing `hand:` convention for treehouse worktree ownership. - -This does not make the label unique, and it is not claimed to. -It changes what a collision requires: another `hand`-managed project coincidentally sharing the same name, rather than any directory on the system. - -A workspace already created under the bare label before this change is **not** adopted. -`hand spawn` creates a new `hand:` workspace alongside it and the old one is orphaned - still functional, just no longer found by lookup. - -## Rejected alternatives - -**Keep the bare project name.** -It puts `hand`'s lookup in a namespace every directory on the machine can write to, and the failure is silent rather than an error. - -**Adopt an existing bare-label workspace on first lookup, then rename it.** -Adoption means deciding that a workspace `hand` did not create is `hand`'s, which is the exact assumption the prefix exists to stop making. -The one case adoption helps is a fleet mid-upgrade; the case it breaks is a same-named workspace belonging to something else. - -**Match on the workspace's root directory instead of its label.** -The root is the worktree's cwd for the first task, and worktrees are recycled by treehouse, so the root a workspace was created at is not stable across the project's life. - -**Refuse to spawn when a bare-label workspace exists, so the operator migrates deliberately.** -It blocks work on a condition `hand` can route around, and the orphaned workspace costs nothing but a stale entry in `workspace list`. - -**Make the label globally unique with a hash or an id.** -The label is what an operator reads in herdr to find their fleet. -A unique label nobody can recognize trades a rare collision for a permanent usability cost. - -## Consequences - -Upgrading a fleet leaves one orphaned workspace per project, which the operator closes by hand or ignores. - -Two `hand`-managed projects with the same name still collide, and nothing detects it. -That is the accepted residue: the prefix narrows the blast radius rather than removing it. - -Anything else `hand` names in another tool's namespace gets the same treatment - `hand:` prefixed and never adopted - because both halves of this decision came from the same mistake. diff --git a/docs/adr/holds-are-their-own-table.md b/docs/adr/holds-are-their-own-table.md index 936ee6c..526d786 100644 --- a/docs/adr/holds-are-their-own-table.md +++ b/docs/adr/holds-are-their-own-table.md @@ -1,4 +1,4 @@ -# A hold is its own row keyed by an arbitrary id, not a column on the task +# Holds are independent of task rows - Date: 2026-08-04 - Status: accepted @@ -7,71 +7,20 @@ ## Context -"What needs the operator" was answered by hand in `data/backlog.md`, which means it was answered by whoever remembered to write it there. -A hold records that an id is waiting on something, so the answer is derived from the store instead. - -The obvious modelling is a column on the task row, or a side table with a foreign key into it. -It fits the common case: a hold is usually about a task. - -It does not fit the motivating case the issue names. -`hand teardown` deletes the task row, so a hold set on a task torn down while its question stayed open would vanish exactly when it matters most. -Work with no task row behind it at all - never dispatched, or torn down mid-question - has nowhere to hang a hold. - -A second, independent reason pointed the same way at the time. -Before atqamz/secondhand#111, `Open` applied the `schema` constant with `CREATE TABLE IF NOT EXISTS` and had no schema-version mechanism. -That is a correct create against a table that does not exist and a silent no-op against one that exists and is merely missing a new column, with no error and no column added. -Adding a `blocked_on`-style column to `task` would have passed every test, since tests build fresh databases, and silently failed to apply to the one `state/hand.db` on disk. -A brand-new table sidestepped it: every existing database was missing the whole table, so the create branch ran on both a fresh and a migrated home. +A question can remain open after teardown removes its task, and work can be held before a task row exists. A task column or foreign-keyed child row disappears at exactly the wrong boundary. ## Decision -A hold is a standalone row keyed by an arbitrary id, with no foreign key into `task`. -It survives `hand teardown` of a task with the same id. - -Three kinds, and no more invented without a new issue: `operator` waiting on a human, `blocked` waiting on another id named in `blocked_on`, and `limit` waiting on the harness's own quota. +A hold is a standalone row keyed by an arbitrary id, with no foreign key to a task. Teardown does not clear human-authored holds. Machine-authored usage-limit holds are projections of task scheduling state and are cleared only after their kind is checked. -Because a hold outlives its task, id reuse is a hazard, so `hand spawn` refuses a held id with exit 3 and names `hand hold clear `. -Clearing is the explicit step that says the question is settled, and it is the only escape hatch. - -`limit` is the one machine-set kind and it is a projection rather than a record. -`hand hold set --kind limit` is refused with exit 2, `hand hold clear` accepts it, and every machine clear checks the kind first so it never answers an operator's question on the same id. -The set direction is guarded the same way and has to be, because a `limit` hold written over an operator's would be deleted along with their question by the machine clear that follows. -`limit` is also the one kind that does not outlive its task, and `hand teardown` releases it. - -A hold that cannot be read must never read as nothing waiting. -`ListHolds` and `ReadHold` surface every row as stored, inconsistent ones included, `hand status` flags an inconsistent row rather than rendering it as valid, and a failure to read holds at all is a hard error out of `hand status` rather than an empty list. +The schema and read/write behavior live in [`internal/store`](../../internal/store), with command and status behavior covered by hold tests. ## Rejected alternatives -**A `blocked_on` column on the task row.** -Destroyed by `hand teardown` at the exact moment a hold matters, and impossible for an id with no task. -Before atqamz/secondhand#111 it would also have silently failed to apply to the one real fleet home while passing every test. - -**A side table with a foreign key into `task`.** -Same teardown problem one layer out, plus a cascade delete that removes the operator's open question as a side effect of cleaning up a pane. - -**Keep authoring holds in `data/backlog.md`.** -That file stays out of scope for holds entirely, and a design that finds itself parsing it has gone wrong. -A prose list is a list of what somebody remembered. - -**Let `hand spawn --force` clear a held id.** -A `--force`-style flag would be the silent clear wearing a different name. -Answering an operator's question is an acknowledgement `hand` has no business making on their behalf. - -**Refuse `hand hold clear` on a `limit` hold too, for symmetry with `hold set`.** -It would make the one hold set on the operator's behalf the one hold they cannot undo. - -**Filter inconsistent hold rows out of `ListHolds`.** -Filtering is what lets an external write's mistake disappear from "what is held". -A `holds[0]` block and "the store could not be read" look identical unless the second is a fatal error. +- A task column or cascading child row cannot outlive teardown. +- Keeping holds in backlog prose makes the answer depend on somebody remembering to edit it. +- A force flag that clears a hold would let the tool answer an operator's question. ## Consequences -The `hold` table has no referential integrity with `task`, so an orphan hold is a legitimate state and every reader has to treat it as one. - -`hand teardown` and `hand promote` both carry kind-checked clears for `limit`, and adding a fourth kind means deciding its teardown behavior explicitly. - -`hand spawn` refusing a held id is contract at exit 3, and it is what makes surviving teardown safe rather than a trap. - -The schema-version reason no longer applies: an ordinary column addition is now two edits that stay in step. -See `the-schema-version-lives-in-pragma-user-version.md`. +Orphan holds are valid. Reusing an id requires an explicit clear, and every new machine-authored hold kind must define its teardown behavior without overwriting a human hold. diff --git a/docs/adr/launch-confirmation-trusts-herdr-not-the-screen.md b/docs/adr/launch-confirmation-trusts-herdr-not-the-screen.md deleted file mode 100644 index 2b6ccee..0000000 --- a/docs/adr/launch-confirmation-trusts-herdr-not-the-screen.md +++ /dev/null @@ -1,77 +0,0 @@ -# Launch confirmation trusts herdr for liveness and the pane text only for dialogs - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#28 -- PRs: none single - -## Context - -`hand spawn` sends a launch command to a pane and has to decide whether a worker actually started. - -Both available signals are unreliable alone. -The pane's text is written by the harness, so a harness that painted a first-run dialog and then exited leaves convincing output behind and no process. -That is atqamz/secondhand#28: a spawn confirmed against text, with the worker parked on a dialog nobody answered. -Herdr's agent labeling knows whether a harness process is in the pane's foreground, but it says nothing about what that process is waiting on. - -## Decision - -The two signals are used for the two different questions, and neither is used for the other's. - -**Liveness is herdr's answer, never the screen's.** -Herdr reports an agent on a pane only while a harness process is in its foreground, so a harness that painted a dialog and exited is never mistaken for a started worker. -That labeling is verified empirically for `claude` and `opencode`, each run in a real pane and observed being labeled. -For `codex`, `pi` and `grok` it rests on herdr's shipped agent-detection manifests, read but not exercised, because no binary for those is installed on this host. - -**Pane text is read only to spot dialogs.** -A known dialog is answered; success needs the pane to hold a live agent and stay free of both known dialogs and the generic unrecognized-dialog fallback for a settle window. -A harness's own readiness signature is a secondary shortcut: on a pane already holding a live agent, the harness's own paint means there is nothing left to settle for. - -**The text comes from recent scrollback, not the visible viewport.** -A pane in an unattached herdr session is too short to show a whole dialog - 23 rows against 61 attached - and what it clips is the lower half, where the option and footer lines that identify a dialog live. - -**Scrollback rests on a measured premise, and its failure direction is chosen.** -Claude Code erases an answered first-run dialog in place rather than scrolling it away, so a recent-scrollback read does not carry answered dialogs forward. -Measured on 2026-07-26 against a real spawned worker pane on the Claude Code version installed on this host, reading 200 lines of retained scrollback: no trust-dialog, bypass-disclaimer or `Enter to confirm` text remained anywhere in it. -So a read that still matches a catalogued dialog is treated as that dialog still being up, and the launch runs out its poll window rather than being confirmed. -If the premise stops holding on a later version, spawn fails on the deadline instead of confirming a healthy worker. -A wrong deadline failure is loud and fixable; confirming an unread dialog is atqamz/secondhand#28 again. - -Independently of the read, each catalogued dialog is answered at most once per launch, so retained text can cost a timeout but can never send a second round of keys into a live agent's composer. - -**Two outcomes are not success.** -A pane with no agent, or one still showing a dialog, when the window elapses fails the spawn with the pane content and what held it up. -For a harness whose agent detection has not been exercised, that failure names the unexercised detection first and a harness that exited on a dialog second, since an unrecognized process is the likelier cause. -A recognized-but-refused dialog fails immediately, naming what a human has to accept. - -## Rejected alternatives - -**Confirm on pane text alone.** -This is what atqamz/secondhand#28 was. -The text a dead harness left behind is indistinguishable from the text a live one is showing. - -**Confirm on herdr's agent presence alone.** -An agent parked on a dialog is a live process that will never do the work. -This is exactly the residual gap for a harness with no catalogued signatures, and it is accepted only because there is nothing else to read for one. - -**Read the visible viewport, since that is what an operator would see.** -An unattached pane clips the half of the dialog that identifies it, and unattached is the normal state for a fleet. - -**Treat retained dialog text as stale and confirm anyway.** -That converts the measured premise from a safety margin into a requirement, and its failure mode is silently confirming a parked worker. - -**Re-answer a catalogued dialog on every poll, in case the first answer was lost.** -On a live agent the second round of keys lands in the composer as input the worker did not write. - -**Scrape the harness's readiness signature as the primary confirmation.** -It is another tool's UI text with the same staleness problem as the dialog signatures, and it answers "the harness painted something" rather than "a process is running". - -## Consequences - -Confirmation needs two mechanisms that can each fail, and every failure fails the spawn rather than confirming it. - -The scrollback premise is dated and version-specific, so it will eventually be wrong. -It fails toward a deadline error naming the dialog, which is why it is safe to depend on in the meantime. - -A harness with no catalogued signatures at all is confirmed on agent presence alone, so an agent parked on an unrecognized dialog reports as started. -That is a known accepted gap, and the reason the signature catalogue matters for every harness added rather than only for `claude`. diff --git a/docs/adr/notify-is-a-filtered-consumer-of-the-event-stream.md b/docs/adr/notify-is-a-filtered-consumer-of-the-event-stream.md deleted file mode 100644 index 0a73ab8..0000000 --- a/docs/adr/notify-is-a-filtered-consumer-of-the-event-stream.md +++ /dev/null @@ -1,85 +0,0 @@ -# The notify hook is a filtered consumer of the event stream, with a fixed membership - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#127 -- PRs: atqamz/secondhand#131 - -## Context - -`hand watch --until-event`'s exit reaches a supervisory session that exists and re-arms. -It has no reach when no session is running, and an unattended fleet is the normal state overnight. - -So there is a second consumer of the same classified events, `config/notify`, whose whole purpose is to reach an operator with nothing watching. -Two questions follow: how it selects events, and which events it selects. - -The selection is the part that goes wrong quietly. -A hook that fires on everything wakes an operator for bookkeeping and gets muted, at which point the fleet has no unattended channel at all. -A hook that fires on too little is silent for the case it exists for. - -## Decision - -The notify hook is its own filtered consumer of the same stream `--event` filters for stdout, not a severity test hardcoded into `handleEvent`. -`internal/watcher.NotifyFilter` builds an `EventFilter` with its own fixed membership, using the identical `EventFilter` and `Matches` mechanism `--event` uses, and `handleEvent` checks it the same way. - -The membership names the kinds worth waking someone for: `blocked`, `report-blocked`, `failed`, `report-failed`, `report-needs-decision`, `report-done`, and `usage-limit-stuck`. - -`report-blocked` is in the set alongside the herdr-transition `blocked` because the two are independent signals, and a worker that reports blocked and then goes idle fires no other notifiable kind: `ClassifyStatus` suppresses `idle-unreported` precisely because the last report state is set. - -`idle-unreported`, `stale`, `parked`, `pr-merged` and the `pr-record-*` kinds are out. -Each describes a transition the poll loop is already tracking toward one of the seven above, or one that resolves without a human. -`usage-limit` and `usage-limit-resumed` are out for that second reason exactly, and `usage-limit-stuck` is in because it is the one of the three that says the mechanism has run out of its own answers. - -`handleEvent` calls `internal/notify.Send` in-process for every match, never by shelling out to the `hand notify` subcommand, so the wiring reaches every caller of `hand watch` with no shell wrapper. -Both modes call it, whether or not the event also reached stdout, so a transition discovered on a restart's baseline tick reaches the operator the same way a live one does. - -An unconfigured `config/notify` produces no diagnostic in the hook, the same silent fallback every other `config/` default gets. -A configured template that fails, or hangs past its timeout, writes one diagnostic to the watcher's stderr and the poll loop carries on. - -The `hand notify` subcommand is the opposite: an absent config, an empty one, a failed template and a timed-out template are all exit 1 there. -It used to print `notified:` and exit 0 with no config at all, which made "not configured" and "delivered" the same observable outcome on the one path meant to reach an operator with nothing watching. -An empty file is the same case in a different shape, since `sh -c ""` succeeds and would claim a delivery just as wrongly, so an empty template is unconfigured rather than a template. -All four mean nothing reached the channel, which is one fact and so one error rather than four codes. - -## Rejected alternatives - -**Hardcode a severity test in `handleEvent`.** -Then the notify set is a condition rather than a value, and it cannot be listed, tested against a table, or compared with what `--event` accepts. -Reusing `EventFilter` means the two consumers differ only in membership. - -**Notify on every event.** -It wakes an operator for `pr-merged` and `stale`, both of which the poll loop is already carrying toward something actionable, and the hook gets muted. - -**Notify on terminal report states only.** -A worker that goes unreachable fires `failed` from `ClassifyUnreachable` and never writes a report line, so a dead worker would be silent on the one channel that exists for an unattended fleet. - -**Shell out to `hand notify`.** -Every caller of `hand watch` would need the wrapper, and a caller that forgot it would have a watcher that detects events and notifies nobody, with nothing distinguishing it from a quiet fleet. - -**Diagnose an unconfigured `config/notify`.** -Most fleets do not configure it, so the diagnostic would be permanent noise on the watcher's stderr for a default that is working as intended. - -**Let `hand notify` stay quiet about an unconfigured channel too, for consistency with the hook.** -The hook is one of many things a tick does and its silence is a default. -The subcommand does exactly one thing, so its silence is a false report of having done it. - -**Warn about an unconfigured channel and still exit 0.** -Exit 0 is what a caller branches on. -A warning behind a success is a delivery claim with a footnote. - -**Let a failed or hanging send end the run.** -The send runs inline in the poll loop, so an unbounded one wedges polling, `--timeout` and shutdown alike. -A hook that cannot be reached must not stop the watcher that reached it. - -**Rate-limit the re-fired `failed` from an already-unreachable pane.** -Its latch is deliberately non-persisted, so a restart re-fires it once for a condition that has not changed. -That is the same duplicate-over-silence trade the poll loop already accepts, and the alternative is a signal that silently stops notifying because the process restarted. -`parked` does not make that trade, and its latch is persisted, because a done task's report file never grows again so every restart re-fires against the same frozen instant (atqamz/secondhand#127). - -## Consequences - -Adding an event kind means deciding its notify membership explicitly, and the default of leaving it out is the safe one. - -`NotifyFilter`'s membership is contract for an operator writing a `config/notify` template, because it is the complete list of what that template can ever be called for. - -The hook is one more thing the poll loop does inline, so its timeout is part of the tick's bounded work that `--until-event`'s worst-case delay is measured against. diff --git a/docs/adr/one-stateful-fake-per-external-tool.md b/docs/adr/one-stateful-fake-per-external-tool.md index 57f6965..970d06d 100644 --- a/docs/adr/one-stateful-fake-per-external-tool.md +++ b/docs/adr/one-stateful-fake-per-external-tool.md @@ -1,4 +1,4 @@ -# One shared stateful fake per external tool, checked against a recorded transcript +# External tools have one shared stateful fake each - Date: 2026-08-04 - Status: accepted @@ -7,61 +7,18 @@ ## Context -`hand` drives three external CLIs: `treehouse` for worktrees, `herdr` for panes, and `gh` for GitHub. -None can run in the test suite, so every suite fakes them. - -The faking was per test: each test scripted the responses it needed for the calls it expected. -That is the cheapest thing to write and it fails in a specific way. -A fake that answers a state-changing command identically before and after that command cannot test anything about the state change. -`hand teardown` returns a worktree and the next `treehouse get` hands out the same slot under a new lease; a scripted fake returns whatever the test author wrote, so the collision guard's whole reason to exist is invisible to it. - -The scripts also drift from the real tools independently. -A version banner on stderr, a JSON payload on stdout, an update-available notice sharing that stderr: each is a detail some scripts happened to get right and others did not, and nothing reconciled them. +Per-test scripts can return expected text without representing the state changed by the previous command. They also let stream placement and response shapes drift independently from the real tools. ## Decision -`internal/faketool` installs **one stateful fake per external CLI**, shared by every suite. -A fake holds the state the real tool holds, so a call that changes something is visible to the next call, and a suite exercises the sequence rather than the individual response. - -`internal/faketool/FIDELITY.md` records what the real tool does for the calls the suite actually depends on, so a fake is checked against a transcript rather than against somebody's memory of the tool. -Only the calls `hand` makes are recorded; a behavior no test exercises does not belong there. -Every entry was observed by running the real binary, not read off its documentation, and each record notes what the call leaves behind rather than only what it prints. - -`tests/contract`, behind the `contract` build tag and `make contract`, re-runs those calls against the real tools and skips where a binary is absent, so a record gone stale against a newer tool is discoverable by running them. -It covers no call that would change anything an operator owns: a scratch treehouse pool, a scratch herdr workspace, and read-only `gh`. -CI never runs it. - -Decorative glyphs in the recorded stderr are omitted from the transcripts, and no matcher may depend on one. +[`internal/faketool`](../../internal/faketool) provides one stateful fake for each external CLI used by the suites. [`internal/faketool/FIDELITY.md`](../../internal/faketool/FIDELITY.md) records observed behavior, and [`tests/contract`](../../tests/contract) checks reversible calls against installed real tools. ## Rejected alternatives -**Keep a script per test.** -It cannot test a state change, which is most of what `hand` does with these tools. -It also multiplies the drift: three tools times every suite, with no single place a corrected observation lands. - -**Record and replay transcripts per test, VCR style.** -The recording is per call sequence, so a test that changes its call order needs a re-record, and a re-record needs the real tools. -It also encodes the state machine implicitly in a tape rather than explicitly in a fake, which is harder to reason about than the tool it is imitating. - -**Make `tests/contract` part of `make test` or CI.** -It needs three real binaries and network for `gh`, and none of them is present in CI. -A required suite that skips is a suite nobody notices has stopped running. - -**Write `FIDELITY.md` from each tool's documentation.** -The details that break `hand` are the undocumented ones: which stream the banner goes to, whether an id is regenerated on reacquisition, what a version older than the floor omits. -Documentation does not carry them and running the binary does. - -**Record every behavior each tool has, for completeness.** -An unexercised record cannot go stale in a way any test would catch, so it is a claim nothing verifies. -The recorded set is the dependency surface, deliberately. +- Per-test scripts test isolated responses rather than command sequences. +- Transcript replay hides the state machine in call ordering and requires rerecording for harmless sequence changes. +- Mandatory contract tests would make the ordinary suite depend on external binaries and network access. ## Consequences -A new call to an external tool means three edits: the fake gains the behavior, `FIDELITY.md` gains the observed record, and `tests/contract` gains the check. -Skipping the second leaves a fake nothing verifies. - -A shared fake is shared mutable state across a suite, so tests have to construct their own fleet home rather than assuming a clean tool. - -`make contract` is in `CONTRIBUTING`'s checklist for a change to how `hand` calls these tools, and it is the operator's own step rather than CI's. - -The fidelity rule generalizes: a fake that cannot represent a state change is not a cheaper test, it is a test of nothing. +A new external call requires a fake behavior, an observed fidelity entry, and a contract check where the call is safely reversible. Tests declare isolated fake state rather than relying on suite order. diff --git a/docs/adr/one-watcher-per-fleet-home-guarded-by-an-flock.md b/docs/adr/one-watcher-per-fleet-home-guarded-by-an-flock.md index f772640..9363eb6 100644 --- a/docs/adr/one-watcher-per-fleet-home-guarded-by-an-flock.md +++ b/docs/adr/one-watcher-per-fleet-home-guarded-by-an-flock.md @@ -1,4 +1,4 @@ -# One watcher per fleet home, and ownership is an flock rather than the pid +# One flock owns watching for a fleet home - Date: 2026-08-04 - Status: accepted @@ -7,50 +7,20 @@ ## Context -Two `hand watch` processes on one fleet home are not a redundant pair. -Each polls herdr independently, each classifies the same transition, and each fires the notify hook, so the fleet's news arrives twice and the hook whose whole purpose is to reach an unattended operator becomes the loudest duplicate of all. - -They also fight over the report channels: `report_offset` is durable and shared, so each watcher consumes lines the other has not seen. - -The way a second watcher actually gets started is a supervisory session that lost the memory of having started the first one. -Compaction drops that memory, and a convention written in `AGENTS.md` is a convention compaction can drop with it. +Two watchers duplicate notifications and race over durable report offsets. Supervisory sessions can forget an earlier watcher after compaction, so agent discipline cannot enforce singleton ownership. ## Decision -`hand watch` acquires ownership of the fleet home before it polls anything, and refuses with exit 3 when another watcher holds it, naming the incumbent's pid and `--takeover` as the remedy. -Validation is at the point of acquisition, in the tool. - -**Ownership is an `flock` on `state/watch.pid`, never the pid the file contains.** -The pid inside is advisory: it names the incumbent in the refusal and lets `--takeover` signal it, and it is trusted only when it arrives newline-terminated, so a read that races the incumbent's own write degrades to `unknown` rather than to some other process's pid. +Streaming and until-event modes share one per-home `flock`. The pid stored beside it is advisory only; kernel lock ownership is authoritative, and takeover proceeds only after the incumbent releases it. -`--takeover` sends SIGTERM, which `hand watch` already handles as a clean shutdown, and waits up to 5s. -If the lock does not come free the takeover fails rather than proceeding. - -Ownership is per fleet home and shared by both modes, so a streaming watcher also blocks `--until-event` against the same home. +[`internal/watcher/ownership.go`](../../internal/watcher/ownership.go) and its unit and end-to-end tests own acquisition, stale pid handling, and takeover behavior. ## Rejected alternatives -**Check whether the recorded pid is alive.** -A lock that a crash can leave held would lock a fleet home out of watching itself, which is worse than having no lock. -Any liveness check is a heuristic that can decide wrongly: the pid may have been recycled, or the signal may be refused for reasons unrelated to liveness. -The kernel releases an `flock` when its holder dies however it died, so there is nothing stale to clear and no heuristic to get wrong. - -**Write the rule in `AGENTS.md` and let the supervisory agent honor it.** -The failure mode is a session that forgot it had started a watcher. -A rule that depends on that session remembering is a rule that fails in exactly the case it exists for. - -**Let a takeover proceed after the 5s wait whether or not the lock came free.** -Two watchers is the condition being prevented, so a takeover that cannot confirm the incumbent is gone must not become one. - -**Give the two modes separate locks, since `--until-event` is short-lived.** -An arming watcher consumes report lines out from under a streaming one. -They contend correctly, and a caller that wants the window says so with `--takeover`. +- A pid liveness check can mistake reuse or permission failure for ownership. +- Separate mode locks still let two consumers advance the same report channel. +- An AGENTS.md convention fails when the session forgets the process it started. ## Consequences -Exit 3 from `hand watch` means another watcher owns the home, and that is contract a caller can branch on. - -`state/watch.pid` is not authoritative for anything. -Reading it to decide whether a watcher is running gives an answer the lock may already have invalidated; taking the lock is the only way to know. - -A test that runs the watcher in-process must skip the takeover signal when the recorded pid is its own process, or it SIGTERMs the test runner. +There is no redundant watcher pair. A takeover that cannot prove release fails instead of risking a second consumer. diff --git a/docs/adr/output-is-toon-by-default-and-json-is-retained.md b/docs/adr/output-is-toon-by-default-and-json-is-retained.md index 98cc990..712f61e 100644 --- a/docs/adr/output-is-toon-by-default-and-json-is-retained.md +++ b/docs/adr/output-is-toon-by-default-and-json-is-retained.md @@ -1,4 +1,4 @@ -# Output is TOON by default and `--json` is retained unchanged +# Output defaults to TOON while existing JSON remains compatible - Date: 2026-08-04 - Status: accepted @@ -7,51 +7,20 @@ ## Context -`hand`'s only consumer is an LLM agent. -The output it shipped before this was a table aligned for a human terminal, which spends context on column padding that carries no information, and a `--json` flag for anyone who wanted structure. - -TOON (https://axi.md) is the shape that fits the real consumer: a schema header naming the columns once, then one comma-joined row per item. -That makes the default an easy call. -What is not easy is what happens to `--json`, because it was already there and callers were already passing it. - -`--fields ` arrived in the same change, narrowing a row block to the named columns in the order named. -It is defined against the TOON schema header, and JSON has no schema header to narrow. -So the two flags together are a request that cannot be honored as asked, and something has to be decided about it. +The primary consumer is an agent, so padded terminal tables waste context. Existing JSON callers are automation outside this repository and cannot be migrated atomically with a format change. ## Decision -TOON is the default output of every command, rendered through the single `internal/axi` renderer rather than per command. +Commands render their default documents through [`internal/axi`](../../internal/axi). Existing `--json` shapes remain compatible. A request combining TOON-only field selection with JSON is rejected instead of silently ignoring one flag. -`--json` is retained everywhere it already existed, byte for byte unchanged. -It is not deprecated, not reshaped to mirror the TOON blocks, and not warned about. - -`--fields` together with `--json` is a usage error, exit 2, naming the reason: `--fields applies to the default TOON output, not --json`. +Renderer tests and command tests own quoting, block shape, field selection, and JSON compatibility. ## Rejected alternatives -**Replace `--json` with TOON.** -Every existing `--json` caller is a script or a hook outside this repo, and none of them is in a position to be migrated by the same commit that breaks them. -The cost of keeping the flag is one branch at the end of each command; the cost of dropping it is a silent break in somebody else's tooling for a benefit the TOON default already delivers. -A caller that wants a parser-backed object should not have to parse TOON in order to build one. - -**Make `--json` a reshaped mirror of the TOON document.** -That is the break above wearing a compatible-looking flag name, and it is worse: the caller gets valid JSON of the wrong shape and finds out at the field access rather than at the flag. - -**Let `--fields --json` win by precedence, silently.** -Either direction is a lie about what ran. -Ignoring `--fields` returns every column to a caller that asked for three; ignoring `--json` returns TOON to a caller that asked for JSON and will hand it to a parser. -The request is not ambiguous, it is unsatisfiable, and the honest answer to an unsatisfiable request is exit 2. - -**Narrow the JSON object by the same field names.** -It would work, and it makes `--fields` mean two different things: a schema-header contract in one mode and a key filter in the other. -The flag's whole definition is that the header narrows with the rows, and JSON has no header. +- Removing JSON breaks existing automation for no benefit to the new default. +- Reshaping JSON under the same flag returns valid data with incompatible meaning. +- Silently choosing one of two conflicting flags lies about the invocation that ran. ## Consequences -Every command has two output paths that both have to stay correct, and the TOON one is the one under test by default. - -`rejectFieldsWithJSON` in `cmd/fields.go` is called by each command that accepts both flags, so adding `--fields` to a command means adding that call. -A command that forgets it accepts the combination silently, which is exactly the outcome this rejects. - -The exit-2 refusal is a contract a caller can depend on, and it is in `SPECS.md`'s "Output shape" and exit-code table. -Softening it later to a precedence rule is a breaking change for any caller that treats exit 2 as a bug in its own invocation, which is what it is. +Commands with JSON support keep two output paths. New default output behavior belongs in the shared renderer rather than per-command formatting. diff --git a/docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md b/docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md index 34c1d97..7cf6f79 100644 --- a/docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md +++ b/docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md @@ -1,4 +1,4 @@ -# Secondhand keeps firstmate's concept and rebuilds its execution as one Go binary +# Secondhand is one Go binary, not an agent distribution - Date: 2026-08-04 - Status: accepted @@ -7,80 +7,21 @@ ## Context -Running one coding agent is easy. -Running three in parallel on different tasks across different projects turns an operator into a tab-juggler: babysitting sessions, copy-pasting context, forgetting which terminal had the failing test. - -Firstmate solved that with an "agent distro": a directory of instructions and shell scripts that turns a general-purpose agent into a fleet supervisor. -The concept worked. -The execution ballooned in six days to 34k lines of shell across 89 scripts, 1,082 functions, 8k lines of prose instructions, 5 backend adapters, a Twitter bot, and a multi-home federation system. - -Three failures were structural rather than incidental, and each one is a property of the shape rather than of any particular script. - -**Session clobbering.** -The supervisory agent's main session drowned in operational noise: bootstrap digests, hook injections, guard warnings, status polling, watcher rearms. -A 21K-token always-loaded instruction file ate context every turn, and long sessions produced malformed tool calls at around 500k context. -The operator's chat became a system log instead of a command center. - -**Shell brittleness.** -macOS bash 3.2 caused silent failures in spawn and brief scaffolding. -Locale inheritance broke checksums and state reads. -BSD versus GNU tool detection failed under mixed toolchains. -Content-hashing terminal panes to detect agent state was fragile at the premise, not in the implementation. - -**Self-imposed complexity traps.** -Session locks deadlocked recovery. -Continuity hooks blocked the very commands needed to fix the problems they detected. -The watcher, guard and hook system created more problems than it solved. +Firstmate proved the fleet-supervision concept but implemented orchestration through shell scripts and a large always-loaded instruction corpus. That shape made portability, recovery, and supervisory context depend on the agent correctly replaying operational procedure. ## Decision -The concept is kept and the execution is rebuilt as a single Go CLI binary, `hand`. - -Everything the three failures point at becomes a standing constraint rather than a preference, and the "Core principles" section of SPECS.md is the contract form of them: +Secondhand keeps the fleet model and moves orchestration into one Go CLI binary. The supervising agent is a client. Fleet homes contain state and prose, not executable orchestration, and ordinary commands are short-lived processes. -- One binary owns orchestration, and there are no shell scripts in the orchestration path. -- AGENTS.md stays tiny, on the order of 25 lines, with operational detail in `--help` instead. -- Agent state comes from herdr's semantic states, never from scraping or hashing terminal output. -- A feature with no proven use case is cut, and gets added when its absence causes real pain. -- The CLI fails closed and reports errors as its own output; it installs no guards, no callbacks and no continuity hooks. +The package boundaries and command help are the current design; this record keeps only why the binary boundary exists. ## Rejected alternatives -**Keep firstmate and pay down its debt incrementally.** -Two of the three failures are properties of the distro shape. -The context cost is inherent to instructions that must be loaded for the agent to operate at all, and the portability failures are inherent to 34k lines of shell across two toolchains. -Neither is reachable by refactoring within that shape. - -**Rewrite in a scripting language with better portability than bash, such as Python.** -It fixes the bash 3.2 and BSD-versus-GNU class of failure and leaves the rest: a runtime to install, a dependency set to resolve per host, and no single artifact to ship. -A static binary is the property being bought here, not the language. - -**Keep the agent-distro model and shrink the instruction file.** -The instruction file is large because the agent is the orchestrator. -Shrinking it without moving orchestration out of the agent trades context cost for an agent that no longer knows how to operate the fleet. - -**Build a daemon with an API, so the supervisory session holds no operational state.** -It removes the noise from the session and adds a process that must be running, supervised and upgraded in step with its clients. -Every command being short-lived is what keeps a fleet home a directory that ordinary tools can copy, back up and inspect. -See `believe-the-status-file-and-ship-no-hand-dump.md`. - -**Port the firstmate feature surface across, minus the shell.** -Much of it exists for a scale nobody has hit, or for a problem the rebuild dissolves. -Multi-home federation coordinates ten or more projects, and starts by making a single home harder to reason about. -Dispatch profiles are a config format and a selection skill wrapped around what `--harness`, `--model` and `--effort` say at the call site, or a brief declares. -A durable wake queue is a second copy of what the watcher already prints to stdout and appends to `state/events.log`, with its own staleness to reason about. -Each one is reachable the day its absence hurts, and principle 5 is what keeps that day the trigger rather than a promise. - -**Keep the hook, guard and continuity machinery, fixed.** -The trap was not that the hooks had bugs. -It was that a guard which blocks a command is a guard that blocks the recovery for the condition it detected. -The one hook `hand` installs is deliberately the opposite: a `SessionStart` entry that runs the bare command, policing nothing. -See `ambient-context-is-a-session-hook-not-a-file.md`. +- Paying down the shell distribution leaves orchestration encoded in instructions and host-specific commands. +- A scripting-language rewrite improves shell portability but retains a runtime and dependency installation surface. +- A daemon adds lifecycle and version coordination to a directory that should remain inspectable with ordinary tools. +- Porting every prior feature preserves speculative complexity instead of adding capabilities after demonstrated need. ## Consequences -The tool ships as one artifact, so a fleet home carries no orchestration code of its own and an upgrade is one binary replacement. - -The supervisory agent is a client rather than the implementation, which is what makes the harness interchangeable at all. - -Every later "should `hand` grow this?" question resolves against principle 5, and the answer for anything speculative is no. +One artifact owns lifecycle correctness and state transitions. AGENTS.md can remain operating guidance rather than becoming the implementation, and adding a feature requires a demonstrated fleet need. diff --git a/docs/adr/the-collision-guard-keys-on-the-lease-id.md b/docs/adr/the-collision-guard-keys-on-the-lease-id.md deleted file mode 100644 index 1c6d583..0000000 --- a/docs/adr/the-collision-guard-keys-on-the-lease-id.md +++ /dev/null @@ -1,59 +0,0 @@ -# The worktree collision guard keys on the treehouse lease id, not the path - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#48 -- PRs: atqamz/secondhand#132 - -## Context - -`hand spawn` and `hand promote` both acquire a worktree and then cross-check it against every other task row before committing to it. -The guard originally compared worktree paths, which is the field a reader reaches for first because it is the one a human recognizes. - -treehouse recycles paths. -A pool slot returned to its pool keeps its directory and is handed straight back out to the next task, under a brand-new `lease_id` that treehouse regenerates on every acquisition, including a same-holder reacquisition of the same slot. -So the path is the one part of a lease that is reused and the identity is the one part that never is. - -Keying on the path produced a false positive rather than a missed collision, and the sequence is entirely ordinary. -`hand teardown` returns the worktree before it removes the task's row, deliberately, so a fault in the later step leaves the whole command retryable. -If that removal fails, the row survives naming a path treehouse has already freed. -The next spawn or promote legitimately acquires that path, matches the stale row on path equality, force-returns its own exclusive lease and fails over a collision that never existed concurrently. - -## Decision - -The guard compares the lease identity treehouse mints per acquisition, `lease_id` from `treehouse get --lease --json`, recorded on the task row. - -Path comparison remains the fallback whenever either side has no identity: a task row written before the `lease_id` column existed, or a treehouse older than v2.1.0, which is the version floor for the field. -Existing rows keep being guarded through the migration and gain a real identity as each task is torn down and respawned, so nothing is rewritten in place. - -Every task row is compared, done and failed ones included. - -The guard is defense-in-depth over `hand`'s own bookkeeping, not the thing preventing two tasks from sharing a worktree. -`worktree.Get` always passes `--lease`, and treehouse's pool lock refuses to hand out a currently-leased slot, so two tasks cannot concurrently hold one path in the first place. - -## Rejected alternatives - -**Keep comparing paths.** -The path is recycled by design, so path equality answers "has this directory ever been used by another task" rather than "is another task holding it now". - -**Filter the comparison to active tasks so stale done rows stop matching.** -Status says nothing about whether a worktree is still held: a task keeps its lease until teardown returns it. -Filtering by status would drop rows that genuinely still hold the slot, turning a false positive into a missed collision. - -**Remove the task row before returning the worktree, so no stale row can exist.** -Then a fault in the return leaves a row-less lease nothing will ever release. -The current order is the one that keeps `hand teardown` retryable as a whole. - -**Drop the guard entirely, since treehouse's pool lock already prevents the real collision.** -It stays as a check on `hand`'s bookkeeping rather than on treehouse's, and it is cheap. -Note that the older justification for it - that it prevented a stale-lease-after-crash bug, firstmate #947 - was wrong: that bug was pid-based ownership, which `hand` has never used. - -## Consequences - -`lease_id` is a nullable column with a real fallback path, so both branches need tests and both are exercised by the `internal/faketool` treehouse fake. - -A treehouse older than v2.1.0 stays usable and silently degrades to path comparison, which reintroduces the false positive above for that operator. -The version floor is documented rather than enforced. - -The retracted firstmate #947 claim is recorded here on purpose. -It read as the guard's justification for long enough that removing the guard looked like removing a crash fix, and it was neither. diff --git a/docs/adr/the-comment-rule-is-two-mechanical-checks.md b/docs/adr/the-comment-rule-is-two-mechanical-checks.md deleted file mode 100644 index 7c0707b..0000000 --- a/docs/adr/the-comment-rule-is-two-mechanical-checks.md +++ /dev/null @@ -1,90 +0,0 @@ -# The comment rule is two mechanical checks, not a judgement about whether a WHY is real - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#98 -- PRs: atqamz/secondhand#160 - -## Context - -`CONTRIBUTING` and the operating rules both said zero comments by default, only a WHY the code cannot show or a functional pragma. -The rule did not bind, and the reason is that it has no checkable form. -A worker can construct a WHY for any comment, and the reviewer is then arguing taste. - -The measurement is what settled it. -Worker-authored added Go comment lines, read in file order: 145 on atqamz/secondhand#75 with 141 holding on review, 78 on #77 with all but two holding, 24 on #65, 91 on #79 all carrying a real WHY. - -So the bodies were mostly defensible. -That is the finding: the rule was passing every case it was applied to while the volume kept climbing, which means it was not the thing doing the work. - -The concentration was in test comments, and they shared one shape. -The first clause restates the test function name, then the real content follows. -The restating line is pure duplication, and it is also the line that makes the block long enough to feel like documentation. - -## Decision - -Two rules replace the unfalsifiable one, both checkable without judgement: - -1. A comment may not open with the identifier it documents. -2. A comment block may not exceed three lines. - -Both are enforced by the lint step rather than by a reviewer reading a diff, and both are stated in `CONTRIBUTING` in their checkable form. - -Rule 1 applies wherever Go's own doc convention does not: unexported declarations, everything in `_test.go`, and comments inside function bodies. -An exported declaration's doc comment is required by convention to open with its name, so it is exempt from rule 1 and not from rule 2. -Exempt from both: the package doc comment, directives, and files carrying the generated-code header. - -Consecutive `//` lines are one block for rule 2, and neither a bare `//` line inside a run nor a blank line above a doc comment breaks it. -Both are ways of writing six lines of prose in front of one declaration while satisfying a three-line rule, and the blank-line form also drops the first half out of godoc. - -Rule 2 will occasionally be wrong: a genuinely subtle invariant sometimes needs four lines. -That is accepted. -A rule that is right most of the time and mechanically enforced binds harder than one that is right always and enforced never. -An escape hatch is added only if a real case appears, not in advance. - -## Rejected alternatives - -**Keep the WHY rule and enforce it harder in review.** -The measurement says the WHY rule was already passing nearly every comment it was applied to. -Enforcing it harder means reviewers rejecting comments that satisfy the stated rule, which is arbitrary rather than strict. - -**Cap the total comment count per file or per diff.** -It penalizes the one long file with three real invariants and permits the short file full of restatement. -The shape being removed is per comment, so the check is per comment. - -**Ban comments in test files outright, since that is where the volume is.** -A test's non-obvious setup is exactly the kind of WHY the code cannot show. -The problem was the restating first line, not the presence of comments in tests. - -**Ship an escape-hatch pragma with rule 2.** -An escape hatch available from the start is the judgement call coming back through a directive, and every four-line block will claim it. -Add it when a real case appears. - -**Make the checks warnings rather than lint failures.** -A warning is the previous state with more output. -The whole point is that the rule binds without anyone adjudicating it. - -**Apply rule 1 to exported doc comments too, for uniformity.** -Go's doc convention requires an exported declaration's comment to open with its name, and godoc renders it that way. -A rule that fights the toolchain gets an exemption written into the checker eventually, so it is written in from the start and scoped instead: rule 1 covers unexported declarations, `_test.go` files and in-function comments, which is where the measured restatement was. - -**Count only a run of `//` lines uninterrupted by anything.** -A bare `//` inside a run, and a blank line above a doc comment, are each a way to write six lines in front of one declaration while satisfying a three-line rule. -The blank-line form also drops the first half out of godoc, so it is worse than the violation it evades. - -## Consequences - -Both rules are syntactic, so they are wrong sometimes in both directions. -A comment that opens with a word that happens to be the identifier is rejected even where the sentence is fine, and a three-line block of pure restatement passes. -That is the trade: mechanical and imperfect over correct and unenforced. - -Every existing violation has to be fixed at once, because a checker with a grandfathered baseline is a checker that never fails. -That was 727 violations across 95 files. - -Prose that outgrows three lines has to go somewhere, and `CONTRIBUTING` sends it to `SPECS.md`. -That is right only for prose a caller can depend on. -Reasoning that outgrows three lines belongs in `docs/adr/`, or `SPECS.md` regrows exactly the way that made it 2831 lines. -See `README.md` in this directory for which is which. - -`tools/commentlint` is the authority for the exemptions, not this record. -A rule this mechanical will accumulate edge cases in the checker, and a record that tried to track them would be the checker written twice. diff --git a/docs/adr/the-completion-store-is-an-uncapped-append-only-sibling.md b/docs/adr/the-completion-store-is-an-uncapped-append-only-sibling.md index 8a6ff89..dea4fa8 100644 --- a/docs/adr/the-completion-store-is-an-uncapped-append-only-sibling.md +++ b/docs/adr/the-completion-store-is-an-uncapped-append-only-sibling.md @@ -1,4 +1,4 @@ -# The completion store is an uncapped append-only log, not a share of `events.log` +# Completions use an uncapped append-only file - Date: 2026-08-04 - Status: accepted @@ -7,72 +7,21 @@ ## Context -`hand teardown` removes the task's row, and after that the fleet holds no record that the task existed. -`hand status` shows the live fleet and never history, by design. -So something has to outlive the row, or "what happened to that task" has no answer at all. - -`state/events.log` was already there and looked like the place for it. -Its writer reads the whole file, appends, and rewrites via a temp-file rename. -That is fine for one long-lived writer, which is what `hand watch` is. -Teardown is not: it is a short-lived process that can genuinely overlap a running watcher, and two read-modify-write cycles racing means one process's rename lands over the other's and a line is gone. +Teardown removes live task state, but a completion must remain readable afterwards. The existing watcher event log is bounded and rewritten by rename, which is unsuitable for concurrent short-lived teardown writers. ## Decision -`state/completions.jsonl` is a sibling of `events.log`, not a share of it. -It takes a dedicated lock and performs one `O_APPEND` write per record - no read, no rename, nothing a second writer can clobber. - -The record is appended **before** the task's row is removed, because the record is derived from the state that removal takes out from under it. -The two sides of that ordering fail in deliberately different directions. -If the append fails, nothing was recorded and nothing was touched, so the whole command is retryable. -If the removal after it fails, the record already written is not thereby wrong: everything it claims was independently true earlier in the same run. -The row is left in place, so a retry replays the command and appends a second functionally duplicate record. -A harmless duplicate is the deliberate trade against ever silently losing a completion. - -The store is uncapped. -It is the only durable record of a completion, so keeping the last N entries throws away the answer to the question it exists for. - -Each line is a complete JSON object - `id`, `project`, `kind`, `outcome`, `detail`, `torndown_at` - readable without parsing prose. +Completions are appended to a dedicated JSONL file under their own lock before the task row is removed. The file is uncapped because it is the durable record of tasks no longer present. -`outcome` ranks `delivered` ahead of every outcome that asserts the work landed, but only while the row carries no merge. -A delivered task has to stay distinguishable from a merged one in the permanent record, or the fleet's history claims upstream merges that never happened (atqamz/secondhand#78). -A delivery an upstream maintainer then really did merge records `merged`, because that is the stronger of the two true facts and the requirement is only that the record never claim a merge that did not happen. - -Teardown's own output carries the record's own `outcome` and `detail` fields, so what the command says and what the permanent record holds cannot drift. +[`internal/completion`](../../internal/completion) owns the format and append operation. Its tests and teardown tests own failure ordering and duplicate tolerance. ## Rejected alternatives -**Write completions into `events.log`.** -Its read-modify-write rename loses a line whenever a short-lived writer overlaps the watcher, and teardown is exactly that. -One append-only file per writer pattern is cheaper than making the existing writer concurrent. - -**Remove the row first, then append.** -The record is derived from the row. -Removing first means either holding the derived values in memory across a fallible write, or appending a record with fields missing. - -**Make the duplicate impossible with a completion id and a dedup read.** -A dedup read is the read-modify-write cycle that this format exists to avoid, and it buys the removal of a harmless duplicate. - -**Cap the store, or rotate it.** -Its whole purpose is answering a question about a task that is gone. -A rotated store answers it for recent tasks and silently stops answering it for the ones far enough back to be worth asking about. - -**Store completions in the sqlite database instead.** -A completion is append-only, read rarely, and needs to survive anything that goes wrong with the schema. -A flat file is readable with `cat` when the database is the thing being debugged. - -**Rank `merged` above `delivered` unconditionally, since merging is stronger.** -The row's `merged` flag is set by `hand merge` and by observation, and a delivered contribution is not merged by either. -Ranking merge first would record a delivery as a merge that never happened, which is the one error this outcome exists to prevent. - -**Have teardown print a summary of its own and let the record be internal.** -Two renderings of one event drift, and the printed one is the one an operator quotes back. +- Sharing the rotating event log can lose one writer's line during concurrent rewrites. +- Removing state before appending destroys the source of the record on a failed write. +- Deduplication adds a read-modify-write cycle to avoid a harmless retry duplicate. +- Keeping completion history only in sqlite removes the plain-file recovery path. ## Consequences -There are now two durable log files in `state/` with different concurrency models, and the difference is not obvious from looking at them. -Anything that starts writing completions has to take the lock and append rather than imitating `events.log`. - -The store grows without bound. -That is accepted: it is one line per torn-down task, and the failure mode of the alternative is losing history. - -A retried teardown can leave duplicate records, so any future reader has to tolerate them rather than assuming one line per id. +Retries may append equivalent records, and readers must tolerate them. The store grows by one small object per teardown rather than silently discarding old completions. diff --git a/docs/adr/the-landed-work-guard-reads-the-work-not-the-record.md b/docs/adr/the-landed-work-guard-reads-the-work-not-the-record.md index 4fb8889..b6567df 100644 --- a/docs/adr/the-landed-work-guard-reads-the-work-not-the-record.md +++ b/docs/adr/the-landed-work-guard-reads-the-work-not-the-record.md @@ -1,4 +1,4 @@ -# The landed-work guard reads the work, not the record, and every unresolved question fails closed +# The landed-work guard reads work and fails closed - Date: 2026-08-04 - Status: accepted @@ -7,83 +7,21 @@ ## Context -`hand teardown` releases a worktree and a pane, which is what makes a task's uncommitted work unrecoverable. -So it is fail-closed: it refuses unless the work is landed. - -The guard was written against the task row, and the row is not always right about the work. - -A dirty worktree refused unconditionally. -The no-mistakes gate's own review-fix round routinely leaves a file edited but uncommitted, and the edit often reproduces content the gate's merged fix already carries. -The operator's only way past it was `--force`, which discards work without looking at it. - -`kind` is the one field `hand spawn` records that nothing can correct afterwards, since `hand promote` only goes scout to ship. -A scout spawned without `--scout` therefore arrives at teardown as a ship row whose shape - a report, no PR - is exactly what the guard refuses. -Again the only way out was `--force`: forcing past a work-may-not-be-landed guard to fix a metadata typo (atqamz/secondhand#129). - -Both are the same failure. The guard was asking the record a question only the work can answer. +Teardown releases the only worktree holding unlanded work. Task metadata can be stale or initially misclassified, so trusting it alone either discards work or forces safe cases through an escape hatch. ## Decision -Where the record is unreliable, the guard reads the work. - -**Dirt is compared by content.** -Teardown proceeds past uncommitted changes when every one of them is a tracked modification whose current content already matches the local default branch's tip byte for byte. -The comparison is content-identical and never path-identical: a same-named file with different content, and a path that merely exists in the base, both still refuse. -Both layers a `git status --porcelain` line reports are compared, index and working tree, each where it reports a change, because an `MM` path whose working copy matches the base still holds a third differing version staged. -Untracked files are never safe - there is nothing in the base to compare them against - so their presence refuses whatever else is safe. - -**A completed scout deliverable is read off disk.** -With no PR found, no merge evidence on the row, `data//report.md` present, and the worktree's branch adding no commit to the local default branch, the task is a completed scout regardless of what `kind` says. -Both halves are required and the branch check is the load-bearing one: a ship task whose PR was never opened still carries its commits, so it still refuses, where "no PR and some file exists" would accept it and discard them. -It is decided last, so it can only answer a case nothing else claims, and merge evidence excludes it outright - `hand promote` leaves the report on disk, so a promoted scout that then merged locally has every shape this case reads and it landed as a merge. - -**Every unresolved question fails closed.** -A failure to resolve, read or parse a ref refuses. -Resolution is local-only with no fetch, so a stale local ref means a real safe case is missed rather than an unsafe one accepted. - -**Ambiguity refuses rather than falling through.** -A branch carrying several PRs that do not resolve to one winner refuses outright rather than degrading to "no PR recorded", because that message means unlanded and this is not that. -The refusal names every PR on the head ref with its repo and state, including ones in losing tiers, since the operator has to resolve the branch rather than the pair that tripped the rule. +Where metadata cannot answer whether work survives, teardown inspects repository content and deliverables. Any unresolved read, parse, ref, or PR ambiguity refuses. `--force` remains an explicit authorization to discard genuinely unlanded work, not a repair for metadata. -**A refusal shows its evidence.** -The dirty-worktree error carries the worktree's `git status --porcelain`, capped at 20 entries plus a count, so the operator is not deciding blind. +[`cmd/teardown.go`](../../cmd/teardown.go) owns the ordered guard. Its focused tests cover content comparison, scout evidence, ambiguous PRs, and retry-safe cleanup. ## Rejected alternatives -**Let `--force` be the answer in both cases.** -It is the answer to "discard work nobody delivered", and using it for a redundant edit or a mis-filed `kind` overloads it until it means nothing. -Its one meaning has to stay narrow enough to be scary. - -**Allow a dirty worktree whose paths all exist in the base.** -Path identity says nothing about content. -It accepts exactly the case that loses work: a real edit to a file that also exists upstream. - -**Compare only the working tree, since that is what the operator sees.** -An `MM` path holds staged content that is neither the working copy nor the base, and that staged content is uncommitted work. - -**Fetch before resolving the base ref, for accuracy.** -A fetch makes the guard depend on the network, and its failure mode is the wrong direction: a newer remote base makes more dirt look redundant. -Local-only errs toward refusing. - -**Make `kind` editable instead, so a mis-filed scout can be corrected.** -That fixes the record and leaves the guard still trusting a field a human typed once. -Reading the work fixes every future instance, including the ones nobody notices to correct. - -**Accept a completed scout on the report's presence alone.** -A ship task with an unopened PR has a report often enough, and it also has commits. -Accepting it discards them. - -**Decide the scout case first, since it is the cheapest check.** -Deciding it first lets it shadow a real PR or a local-only merge. -Last means it answers only what nothing else claims. +- Trusting a dirty bit or task kind confuses recorded metadata with the work itself. +- Fetching before comparison makes a destructive guard depend on network state. +- Accepting a report file alone can discard a ship branch that still has commits. +- Falling through on ambiguity turns an unanswered question into permission to clean up. ## Consequences -The guard now runs git plumbing rather than reading two columns, so it is slower and it can fail in more ways. -Every one of those ways refuses, which is the accepted cost. - -`--force` keeps one meaning, and the checks that were forced past before are now questions the guard can answer. -The ordering between the checks is load-bearing rather than incidental, so a new check cannot simply be appended: it has to be placed against what may shadow it. - -The refusal messages carry evidence, which makes them long. -That is deliberate for a fail-closed guard, where a terse refusal sends the operator to `--force`. +The guard does more local inspection and can refuse safe work when evidence is unavailable. That false refusal is preferred to losing the only copy of work. diff --git a/docs/adr/the-report-channel-is-the-only-outcome-signal.md b/docs/adr/the-report-channel-is-the-only-outcome-signal.md index a7934c1..4012fce 100644 --- a/docs/adr/the-report-channel-is-the-only-outcome-signal.md +++ b/docs/adr/the-report-channel-is-the-only-outcome-signal.md @@ -1,4 +1,4 @@ -# The report channel is the only source of a task's outcome +# The report file owns worker outcome - Date: 2026-08-04 - Status: accepted @@ -7,60 +7,21 @@ ## Context -`hand` does not run workers. -herdr does, and herdr reports a pane's agent state: `working`, `idle`, `blocked`, `done`, `unknown`. - -The design principle everywhere else in `hand` is that dynamic state is queried from the tool that owns it and never copied into `state/hand.db`. -`state/.status` breaks that principle, and it is worth being precise about why, because on first reading it looks like exactly the duplicated-state mistake the principle forbids. - -herdr's states answer "is the pane busy". -They do not answer "why did it stop" or "what happened". -`idle` and `done` in particular are the same fact for a headless fleet: whether the pane's harness printed a completion banner a human happened to be present for is not information about the task. - -In production that gap meant `done`, `blocked` and `needs-decision` went unreported. -A worker finished, its pane went quiet, and nothing in the fleet could tell that quiet apart from a worker that had wedged. +Herdr owns whether a pane is busy, but its idle and done labels do not explain why a headless worker stopped. A worker must also be able to report when the `hand` binary or database is the thing being repaired. ## Decision -`state/.status` exists as an append-only text file the worker writes and `hand` only ever reads. -Its vocabulary is fixed: `working`, `paused`, `blocked`, `needs-decision`, `done`, `failed`. +`state/.status` is the sole source of what a worker says happened. It is a plain worker-written, hand-read file with a fixed small vocabulary. Herdr state is independent liveness evidence, and completion claims are checked against artifacts before the tool treats them as verified. -It is not a copy of herdr's agent state and does not duplicate any field herdr owns. -It is the only source of task outcome there is, and herdr's state is consulted only for whether the pane is busy. - -A `done` line is a claim, not a fact. -It is cross-checked against completion evidence the worker did not produce (a ship task's merge, a scout task's `report.md`) before it is allowed to change anything, and until then it surfaces as `reported-done`. - -Only a `hand send` message carries an operator decision. -A worker answering its own harness's question dialog is deciding for itself, and writes that as `working: deciding myself: because ` rather than attributing it to the operator (atqamz/secondhand#87). +When a stored projection and the file disagree about the worker's report, the file wins. There is no database-backed replacement or recovery dump. [`internal/state/report.go`](../../internal/state/report.go), status rendering, and watcher tests own parsing and acknowledgement behavior; [`internal/agentsmd`](../../internal/agentsmd) owns the worker guidance. ## Rejected alternatives -**Infer the outcome from herdr's `idle`/`done` split.** -This is the alternative a future worker is most likely to reach for, because it removes a file and a vocabulary. -It does not work: for a headless fleet the split carries no outcome information at all, only whether somebody was looking. - -**Have `hand` persist the outcome to `state/hand.db` and let the worker call a subcommand to set it.** -It makes reporting depend on a working `hand` binary at the exact moment a worker may be reporting that something is broken, and it makes the recovery path a database read rather than `cat`. -See `believe-the-status-file-and-ship-no-hand-dump.md`. - -**Let the worker write free-form prose and classify it with a model.** -The vocabulary is six words because a classifier that is right most of the time turns every terminal report into a probability. -Malformed lines are surfaced as malformed, never guessed at and never silently dropped. - -**Trust a `done` line on its own.** -Tried, and it is how a task that had not landed anything read as complete. -A worker's belief about its own completion is the least reliable claim in the system, because a worker that is wrong about being finished has no way to know it. +- Inferring outcome from herdr confuses attention state with task completion. +- Requiring a reporting subcommand makes the recovery channel depend on the binary and database it may be reporting about. +- Free-form model classification turns terminal state into a probability. +- Trusting a worker's done claim alone lets mistaken completion become fleet truth. ## Consequences -The brief a supervisory agent writes must carry the channel's absolute path and its vocabulary, or the worker never reports. -That is a real coupling between brief-writing and this design, and it is why `hand spawn`'s prompt carries it. - -The channel lives and dies with the task: `hand teardown` deletes it alongside the row, so a respawned id does not inherit the previous run's log. - -Because the outcome arrives as text a worker appends at will, every reader has to tolerate a partial line, a rewrite in place, and free text after a real report. -Those tolerances are contract, and the reasoning for the hardest of them is in `the-report-offset-is-trusted-only-with-a-digest.md`. - -Evidence for a `done` usually lands after the line is consumed, so the verified `done` fires on a later tick than the report. -That deferral is why `done_verified` is durable rather than re-derived; see `the-watcher-persists-what-it-announces.md`. +Every reader must tolerate partial writes and incorrect append behavior without inventing a report. The channel is removed with the task so a reused id starts clean, while plain text remains available to ordinary recovery tools. diff --git a/docs/adr/the-report-offset-is-trusted-only-with-a-digest.md b/docs/adr/the-report-offset-is-trusted-only-with-a-digest.md deleted file mode 100644 index 900d290..0000000 --- a/docs/adr/the-report-offset-is-trusted-only-with-a-digest.md +++ /dev/null @@ -1,71 +0,0 @@ -# The report offset is trusted only together with a digest, and is the only acknowledgement marker - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#140, atqamz/secondhand#149 -- PRs: atqamz/secondhand#150 - -## Context - -`hand watch` tails each task's `state/.status` from a byte offset persisted as `report_offset`, so a restart resumes where it stopped without replaying announced lines or dropping ones written just before it. - -The channel is specified as append-only, and workers do not all honor that. -A worker reporting with a truncating `>` redirect rewrites the file in place, and then a byte offset taken from the old content is pointing into the middle of a line that no longer exists. - -The first fix was a newline check: every offset the reader persists sits immediately past a newline, so an offset whose preceding byte is not a newline is stale and tailing restarts from the beginning. -Without it, the fragment read from mid-line classified as a `malformed report` naming a healthy worker and quoting a mid-word slice of that worker's own well-formed report (atqamz/secondhand#140). - -The newline check is necessary and not sufficient. -When the rewrite's total length happens to equal the offset, the offset sits at the end of the file with the file's own final newline behind it, which is byte-for-byte what "nothing was appended" looks like. -Reports are one line of house-style prose and consecutive ones run within a few characters of each other, so this is a matter of time rather than a contrived input (atqamz/secondhand#149). - -The cost of that collision is not a missed wake. -Deferred verification is gated on the last recorded report state, so a same-length `done:` rewrite means a worker that finished is never announced as finished. - -Separately, `hand status` needs to answer whether a terminal report reached anybody at all, so that a `done` with no session, no watcher and no notify hook is visible rather than merely eventual. - -## Decision - -`report_offset` is trusted only together with `report_digest`, a digest of exactly the bytes the offset consumed. -The pair is one value: a digest that no longer matches discards the offset with it and tailing restarts from the beginning. - -The digest covers the consumed prefix only, never the unconsumed tail a worker may still be writing. -An empty digest, from a row written before the column existed or a task whose worker has not reported yet, falls back to the newline check alone, so an upgrade replays nothing. - -`report_offset` is also the acknowledgement marker, and there is no second one. -Advancing it already means announced, because the poll loop persists it only after the tick's events are announced, and every announcement reaches `state/events.log` and the notify hook whether or not it reached anyone's stdout. -A terminal line past the offset reached nobody; one behind it reached at least the durable log. - -Where the two readers must part company, they do so in the direction of a duplicate: an unterminated trailing terminal line is left unconsumed by the watcher and still counted as unacknowledged by `hand status`. - -## Rejected alternatives - -**Detect the rewrite from the file's mtime or inode.** -An mtime is granular enough to miss a rewrite inside its own resolution. -An inode is unchanged by precisely the in-place rewrite at issue. -Neither reads the thing that actually changed, which is the bytes. - -**Keep the newline check alone and accept the same-length case as unlikely.** -The inputs are one-line prose reports written by the same generator, so consecutive lengths cluster rather than spread. -The failure is silent and terminal: a completed task never announced as complete. - -**Digest the whole file rather than the consumed prefix.** -Then a worker appending a new line invalidates the offset that correctly describes everything before it, and every append tails from the beginning. - -**Add an `acknowledged` column.** -It would store the same fact twice with a way for the two to disagree, and the derived reading is already exact. -`report_digest` is not that second marker: it records nothing about what reached anyone, only whether the offset beside it still describes the file it came from. - -**Reject a stale offset by erroring rather than restarting from the beginning.** -Re-announcing a report the operator has already seen costs a wake. -A fabricated malformed report costs a wake and misrepresents the worker, and a wedged watcher costs the whole fleet. -Duplicate over silence is the standing direction here. - -## Consequences - -Every write of `report_offset` must write `report_digest` in the same statement, or the pair stops being one value. - -A rewritten channel reads as entirely unacknowledged, which is correct: no watcher announced a line of it. - -A worker mid-append is flagged `unacknowledged` for the moment its line is incomplete. -That is the safe direction, the same one taken when a watcher is denied the task lock. diff --git a/docs/adr/the-schema-version-lives-in-pragma-user-version.md b/docs/adr/the-schema-version-lives-in-pragma-user-version.md index 278a882..a3ba650 100644 --- a/docs/adr/the-schema-version-lives-in-pragma-user-version.md +++ b/docs/adr/the-schema-version-lives-in-pragma-user-version.md @@ -1,4 +1,4 @@ -# The schema version lives in `PRAGMA user_version`, and a fresh database never replays migrations +# Schema versioning uses PRAGMA user_version - Date: 2026-08-04 - Status: accepted @@ -7,68 +7,21 @@ ## Context -Before this, `Open` applied the `schema` constant with `CREATE TABLE IF NOT EXISTS` on every open and had no version concept. -That is correct for a table an existing database is missing outright, which is why adding the `hold` table needed nothing else. -It is a silent no-op for a *column* an existing table is missing: sqlite satisfies "if not exists" at the table level and never looks at the column list again, so no error is raised and no column is added. - -The asymmetry is the dangerous part. -Tests build fresh databases, so a column addition passes every test. -The one real fleet home on disk silently never gains it. - -There is also only one real fleet home, and it predates any version mechanism, so whatever the mechanism is it has to open that home without refusing it. +`CREATE TABLE IF NOT EXISTS` adds a missing table but silently leaves an existing table without newly declared columns. Fresh test databases therefore cannot prove an older fleet home upgrades safely. ## Decision -`Open` gates every other statement on `PRAGMA user_version`, sqlite's own counter for exactly this: no extra table, free to read, and part of the database file rather than a row a stray write could get out of sync with the tables it describes. - -Version 0 is the schema the `schema` constant builds. -0 means "the baseline schema this commit ships", not "unknown, refuse to proceed". - -`migrations` is an ordered list of SQL statements, one per change since that baseline, each moving `user_version` from its index to index+1. -An ordinary column addition is two edits that stay in step: the column goes into `schema` so every new database is built with it, and the matching `ALTER TABLE` is appended to `migrations` so every existing database gains it on next open. +The sqlite file's `PRAGMA user_version` gates ordered migrations. The current schema creates fresh databases directly at the latest version; existing databases apply pending steps transactionally under the schema lock. A database newer than the binary is refused before other statements run. -A brand-new database never replays migrations. -`migrateSchema` checks for the `task` table first, and on that path creates the tables and stamps `user_version` straight to `len(migrations)`, both in one transaction. - -A database newer than the binary is refused wrapping `ErrSchemaNewer` before a single statement runs against the tables. - -Applying pending migrations takes `SchemaLock`, and each pending step on an existing database runs in its own transaction after the baseline exec. - -A column whose empty default would be wrong for an existing row takes a third edit, a backfill `UPDATE` alongside its `ALTER TABLE`, and `readLegacyTask` computes the same value. +[`internal/store/schemaversion.go`](../../internal/store/schemaversion.go) and its tests own the exact migration protocol. ## Rejected alternatives -**A `meta` table row holding the version.** -It is a row a stray write can desynchronize from the tables it describes, and it costs a read of a table that may not exist yet. -`user_version` travels with the file. - -**Treat version 0 as unknown and refuse it.** -It would stop the one fleet home that exists from opening the moment the mechanism merged. - -**Freeze `schema` at the baseline forever and read every column addition out of `migrations`.** -The constant would then lie about the current layout, with nothing but prose to stop the next reader from adding a column to it. - -**Keep `schema` current and replay migrations on every database, fresh ones included.** -Every fresh `hand init` breaks with "duplicate column name" while already-migrated homes keep working. -That is the tests-pass, production-fails asymmetry inverted, which is the exact failure this exists to remove. - -**Stamp `user_version` in a second transaction after creating the tables.** -A crash between them leaves a home carrying the migrated columns while reading as version 0, and every later open replays those migrations against columns that are already there. - -**Rely on sqlite's per-statement locking instead of `SchemaLock`.** -It cannot make "add this column, then bump the version" atomic across a whole `Open`, so two processes opening the same freshly-upgraded home both run the `ALTER TABLE`. - -**Backfill every new column for safety.** -An empty retry stamp is exactly "this task is not waiting on quota", which is the honest reading of every row written before `hand` could detect a limit at all. -A backfill there would invent state. +- A metadata row can diverge from the tables it describes. +- Treating version zero as unknown would reject every database created before versioning existed. +- Replaying migrations against fresh current-schema databases produces duplicate-column failures. +- Guessing at a newer schema risks writes the old binary cannot represent. ## Consequences -Adding a column is a two-edit change with a fixed shape, and adding one to `schema` alone is now the mistake the mechanism exists to catch rather than a silent no-op. - -A backfilled column is three edits, and the third is `readLegacyTask`, because a legacy JSON import lands as an `INSERT` no migration step ever runs over. - -An old binary refuses a new database rather than writing malformed rows into it, so a rollback is a refusal to start rather than corruption. - -The workaround that made `hold` a new table is no longer needed for its schema reason, though the teardown reason still stands on its own. -See `holds-are-their-own-table.md`. +Adding a column requires a current-schema edit and an ordered migration, plus a legacy-import backfill when the empty value is not valid for old rows. diff --git a/docs/adr/the-until-event-exit-is-the-delivery.md b/docs/adr/the-until-event-exit-is-the-delivery.md index 8eea248..765a1e4 100644 --- a/docs/adr/the-until-event-exit-is-the-delivery.md +++ b/docs/adr/the-until-event-exit-is-the-delivery.md @@ -1,4 +1,4 @@ -# The process exit is how an event is delivered to a supervisory agent +# Process exit delivers watcher events to the supervisor - Date: 2026-08-04 - Status: accepted @@ -7,74 +7,20 @@ ## Context -Detecting a fleet event and delivering it to a supervisory agent are separate problems, and streaming solves only the first. - -`hand watch` never exits. -A supervisory agent's background-task runner re-invokes the agent when a process *exits*. -So a streaming watcher's stdout is a file that gets read only if the agent independently decides to look, and "remember to check the watcher" is not a mechanism. -It failed on 2026-07-28. - -The wrapper that stood in for a mechanism was `hand watch | tee log | grep -m1 `, and it failed in four distinct ways. -`grep` matched a worker that was already `done` when the pipeline started, exited, and left the pipeline half-alive with nobody reading the two real events that followed. -It had to exclude `idle-unreported` from its pattern to avoid a wake storm, which is the exact signal it was built for. -A worker whose pane could not be reached at all produced no match and no diagnosis, so the caller waited out the full window. -And the caller had to assemble the pipeline correctly every time. +A streaming watcher detects events but does not wake a supervising agent. Background task runners resume the agent when a process exits, and shell wrappers around a never-ending stream cannot reliably distinguish startup state, timeouts, and probe failures. ## Decision -`hand watch --until-event` makes the process exit the delivery. -It arms, takes a silent baseline, polls, and on the first tick that produces any event writes that tick's events to stdout and exits 0. - -Four rules make that exit trustworthy, each closing one of the wrapper's failures: - -The startup state is never an event. -Only a change from the baseline exits. - -Every wake trigger is edge-triggered, `idle-unreported`, `stale` and `parked` included. -A worker fires once on entering a condition and not again until it leaves and re-enters, so no signal has to be excluded to avoid a storm. - -Arming can fail loudly and distinctly. -A task whose pane fails its arm-time probe is exit 5 naming that task, because a task invisible to the first probe has no transition to ever fire on. - -The exit code says which happened: 0 an event was delivered, 4 no event, 5 a named task's arm probe failed, 3 another watcher owns this home, 1 the watcher itself failed, 2 a usage error. -A caller can never read a crash or a quiet window as fleet news. - -Baseline events are withheld from stdout only. -They still reach `state/events.log` and the notify hook, because the report lines behind them are consumed either way. +Until-event mode takes a baseline, exits after the first new matching event, and uses distinct exits for delivery, an empty window, and an unprobeable task. Startup state is not an event. -One invocation delivers one wake. -Re-arming is the caller's own next step after acting on the exit. +[`internal/watcher`](../../internal/watcher) owns the state machine. Unit and end-to-end watcher tests own filtering, arming, timeout, signal, and exit behavior. The generated fleet workflow tells supervisors to re-arm it. ## Rejected alternatives -**Keep streaming and rely on the agent to read the log.** -This is the state that failed. -Nothing in the agent's loop obliges it to look, and the failure is silent. - -**Wrap streaming in `tee` and `grep -m1`.** -This is what was replaced. -Every one of its four failures is a property of the wrapper rather than of the pattern being matched, so no better pattern fixes it. - -**Have the watcher call back into the agent rather than exiting.** -There is nothing to call: an agent with no session running is not addressable, and one with a session running is already re-invoked by the exit. -The unattended case is answered separately by the notify hook; see `notify-is-a-filtered-consumer-of-the-event-stream.md`. - -**Return 0 on a timeout and let the caller check stdout for emptiness.** -Then a crash, a quiet window and a delivered event are one exit code apart from each other only by output shape, and a caller that gets it wrong reads a crash as fleet news. -Signals take exit 4 for the same reason: nothing was delivered, so it is not 0. - -**Fold the arm-probe failure into the timeout.** -The caller would wait out the full `--timeout` for a cause it can never see on stdout. -A timeout during arming stays 4, because no single task can be named as the cause the way 5 promises. +- A permanent stream still requires the agent to remember to read it. +- `tee` and first-match wrappers confuse existing state with a transition and lose diagnosis outside their pattern. +- Returning success on timeout makes an empty window indistinguishable from delivered news. ## Consequences -The exit code table is contract, enforced at the point of exit and tested in `tests/e2e`. - -Worst-case delay from a transition to the exit that delivers it is one poll interval plus that tick's bounded work. -The `gh pr view` check for a task with a recorded unmerged PR is the only unbounded-looking piece and is capped per task and run one at a time. - -The agent's loop becomes: arm the watcher, read `hand status` and `state/events.log` for current truth, treat the next exit as the answer to what changed since arming. -Anything landing between one exit and the next arming is in those same two places. - -Because arming consumes report lines, an arming watcher and a streaming watcher cannot share a fleet home; see `one-watcher-per-fleet-home-guarded-by-an-flock.md`. +One invocation delivers one wake and must be re-armed. Events consumed during baseline remain in durable channels even though they do not cause that invocation to exit. diff --git a/docs/adr/the-watcher-persists-what-it-announces.md b/docs/adr/the-watcher-persists-what-it-announces.md deleted file mode 100644 index f3d5322..0000000 --- a/docs/adr/the-watcher-persists-what-it-announces.md +++ /dev/null @@ -1,69 +0,0 @@ -# The watcher persists what it announces, and forgets what the pane anchored - -- Date: 2026-08-04 -- Status: accepted -- Issues: atqamz/secondhand#75, atqamz/secondhand#127, atqamz/secondhand#128 -- PRs: atqamz/secondhand#125, atqamz/secondhand#138 - -## Context - -`hand watch` holds an in-memory `TaskState` per task and is restarted constantly. -`--until-event` restarts on every delivered event by design, so on a busy fleet the process lifetime is shorter than most of the intervals it measures. - -Every fact in that struct therefore needs an answer to "what happens to it on restart", and there are only two: persist it, or re-derive it. -Getting that wrong produced a family of defects that all look different and share one cause. -Re-deriving `status_changed_at` from the resume time erased a real dwell on every restart, silencing `stale` for exactly the fleet it exists to watch (atqamz/secondhand#75). -Re-deriving the `parked` latch re-fired against a frozen instant and evicted real history from the capped `state/events.log` (atqamz/secondhand#127). - -`hand promote` is the second axis, and it is sharper. -It rewrites the task row in place, keeps `id` and `created_at`, and gives the task a **new herdr pane**. -A `created_at` identity check therefore passes untouched, and a running watcher writes its cached copy of the scout's facts straight back onto the freshly-rewritten ship row on the next tick. -A promoted ship inherited the scout's last report as if it were its own. - -## Decision - -**Anything the watcher announces is persisted at the moment it announces it, never re-derived on restart.** -`report_offset` and `report_digest`, `pr_merged_observed`, `done_verified`, `last_report_state` and `last_report_note`, `parked_fired_for`, and `usage_limit_retry_at` with `usage_limit_attempts` are all written after their line goes out. - -A fact may be re-derived only when re-deriving it costs at most one duplicate announcement against a clock that keeps moving. -The `stale` latch and the first-sighting outage latch qualify; the dwells they are measured against do not, and are persisted. - -Where duplicate and silence are the only two options, the choice is the duplicate. -The single exception is the usage-limit schedule, where the duplicate is a steer typed into a live pane rather than a line on stdout, so an unparseable stamp resumes unlimited. - -**For a cached fact the governing question is not "is it durable" but "was it anchored to the pane."** -Both halves are handled: `hand promote` clears the durable fields itself, because no watcher may be running, and a running watcher explicitly forgets its in-memory copies, because the identity check cannot see a promote. - -A dwell timestamp is trusted only alongside the status it describes, persisted as `status_changed_for`, since a status observed in a different pane is a new dwell even when it spells the same word. - -Anything added to `TaskState` is classified in `SPECS.md`'s "What survives a `hand watch` restart" table before it ships. - -## Rejected alternatives - -**Re-derive everything on restart and keep no watcher bookkeeping in the store.** -It is the smaller schema and it is how the defects above happened. -Evidence that lands while the watcher is down makes the restarted process conclude the line already went out, so the announcement is skipped silently. - -**Persist every latch, so nothing can ever duplicate.** -Each persisted latch is a column that must be cleared on teardown and on promote, and a latch never cleared is a signal that never fires again. -Persisting a latch whose clock keeps moving buys nothing: the condition has to genuinely re-mature before it can fire twice. - -**Use `created_at` alone as task identity and let it cover promote too.** -Promote deliberately keeps `created_at`, because the task is the same task to an operator. -So identity cannot be the mechanism, and pane anchoring has to be asked about field by field. - -**Let `hand watch` clear the promoted task's stale facts rather than having promote do it.** -A watcher may not be running when a promote happens, and then nothing clears them at all. - -**Rate-limit duplicate notifications instead of persisting the `parked` latch.** -Rate limiting hides the symptom and leaves the frozen-instant re-fire in place. -A done or failed task's report file never grows again, so there is no later edge to rate-limit toward. - -## Consequences - -The restart table in `SPECS.md` is contract for anyone extending the watcher, and a new `TaskState` field that is not in it is an unreviewed decision. - -`hand promote` and `hand teardown` both carry clearing logic that has to grow with the watcher's durable fields. -A new durable field means an edit in three places: the store schema, promote's clear, and the watcher's in-memory forget. - -`report_offset` surviving a restart is what makes `--until-event`'s second baseline tick necessary, since those unconsumed lines are new to the poll loop but not to the file. diff --git a/docs/adr/usage-limit-detection-is-a-harness-capability.md b/docs/adr/usage-limit-detection-is-a-harness-capability.md index 556db0e..35384c9 100644 --- a/docs/adr/usage-limit-detection-is-a-harness-capability.md +++ b/docs/adr/usage-limit-detection-is-a-harness-capability.md @@ -1,4 +1,4 @@ -# Usage-limit detection is a harness capability, not a condition in the poll loop +# Usage-limit detection is a harness capability - Date: 2026-08-04 - Status: accepted @@ -7,61 +7,21 @@ ## Context -A worker whose harness runs out of quota stops mid-task with the reason on screen and nothing else. -To the rest of the poll loop that stop looks like any other stop, so without something specific the task sits dead until a human notices. - -Recognizing it means reading a pane's scrollback and matching wording that belongs to one harness. -Only `claude` has wordings anybody has observed against a real limited run; `codex`, `pi`, `grok` and `opencode` do not, and inventing signatures for them would be guessing about text that stops a worker. - -The shape this could take was decided by history. -atqamz/secondhand#81, #84 and #85 each grew the poll loop one conditional at a time, and atqamz/secondhand#128 was the bill for it. +A stopped worker may show a harness-specific quota refusal in its pane. Generic watcher heuristics risk reading and steering unrelated harnesses, including a plain shell pane. ## Decision -`internal/harness` owns a per-harness catalogue of usage-limit signatures: which wordings mean out of quota, and how to read a reset instant out of them. -It exposes `SupportsUsageLimit` and `DetectUsageLimit`. - -Only `claude` is in the catalogue. -Every other harness declines: one map lookup, no pane read, no steer, and no way for a bare shell pane to be typed into. - -Teaching `hand` about a second harness is an entry in that catalogue, not a branch in the watcher. -The bar for adding one is a refusal catalogued against a real limited run, the same bar `firstRunPrompts` holds. +Recognition and reset parsing belong to a per-harness catalogue in [`internal/harness`](../../internal/harness). The watcher asks whether the capability exists before reading a pane and treats reset times as scheduling hints, never proof that service resumed. -Recognition is anchored on the quota being *reached*, never on the word "limit" alone, so the harness's own approaching-your-limit warning cannot read as a stop. - -A reset instant is only ever a prediction. -It decides when to start trying, never whether the limit is over: that is observed from the pane, and the freshest refusal on screen is the one read, since an older one in scrollback names a reset that has already come and gone. +Only signatures observed from a real limited run are added. [`internal/watcher/usagelimit.go`](../../internal/watcher/usagelimit.go) and focused tests own scheduling, locking, holds, and retry bounds. ## Rejected alternatives -**Detect the stop in the watcher with a heuristic over any pane's text.** -This is the shape #81, #84 and #85 took. -Each conditional was individually reasonable and the loop became untestable in aggregate, which is what #128 paid for. -It also means every harness gets probed with wording that belongs to one of them, and a bare shell pane gets typed into. - -**Catalogue plausible signatures for every harness now.** -A signature nobody has seen a real limited run produce is a guess about text that decides whether to steer a live pane. -Declining costs a stranded worker a human noticing; a wrong match costs a working pane an unwanted steer. - -**Match on the word "limit".** -`claude` prints an approaching-your-limit warning that does not stop the turn, so this reads a working worker as limited. - -**Wait out the reset instant and declare the quota back.** -The instant is the harness's own prediction and it is routinely wrong in both directions. -An attempt produces either a pane that starts working or a fresh refusal, and the refusal is the observation the next attempt is scheduled from. - -**Read the reset from the first match in scrollback.** -Scrollback holds every refusal the harness has ever printed, and the earlier ones name resets the harness has itself superseded. +- Poll-loop text heuristics mix harness policy into orchestration and can steer the wrong pane. +- Plausible unobserved signatures trade a visible stranded worker for silent unwanted input. +- Matching a generic word such as limit mistakes warnings for stops. +- Declaring recovery at a predicted timestamp trusts an estimate instead of observing the pane. ## Consequences -`SupportsUsageLimit` gates the pane read, so a fleet of non-`claude` workers pays one map lookup per task per tick and nothing else. - -The durable state is two task columns, `usage_limit_retry_at` and `usage_limit_attempts`, with the `limit` hold as the operator-visible projection. -The schedule is the authority and the retry path reads the columns, never the hold; see `holds-are-their-own-table.md`. - -The failure mode designed against is a retry storm against an account that is still limited, and five bounds hold it: a floor of ten minutes, doubling backoff capped hourly, every wait capped at 24 hours, exactly one attempt per due window against a durable schedule, and `usage-limit-stuck` announced once after six attempts. - -An attempt is the same two-call steer `hand send` performs and takes the same `send:` lock, without waiting, so a poll tick never blocks behind an operator's `--wait`. - -An unparseable retry stamp resumes *unlimited*, which is the one place the watcher prefers silence to a duplicate, because the duplicate here is a steer into a live pane rather than a line on stdout. +Unsupported harnesses receive no pane read or automatic steer. Supporting another harness requires evidence from its real refusal surface and a catalogue entry rather than a watcher branch. diff --git a/internal/agentsmd/agentsmd.go b/internal/agentsmd/agentsmd.go index ec665be..e53459a 100644 --- a/internal/agentsmd/agentsmd.go +++ b/internal/agentsmd/agentsmd.go @@ -1,7 +1,6 @@ // Package agentsmd generates and refreshes the AGENTS.md workflow/rules -// template that hand init writes into a fleet home, and checks an existing -// one for perishable content and generated-block drift, the checks SPECS.md -// specifies under "hand doctor". generatedBody is the template itself. +// template that hand init writes into a fleet home, and checks an existing one +// for perishable content and generated-block drift. package agentsmd import ( @@ -82,7 +81,7 @@ The session overview's ` + "`config`" + ` block is what this fleet dispatches wi - Nothing under ` + "`data/`" + ` is written for the operator to read. Report to them in the session; ` + "`hand status`" + ` and the issue tracker are their view of the fleet, so a decision taken under a standing grant belongs on the PR or issue it concerns, not in a fleet file. - For no-mistakes projects, workers use ` + "`no-mistakes axi`" + ` directly in the worktree. - Use ` + "`hand search `" + ` to find historical context in data/. ` + "`qmd search`" + ` adds semantic matching when installed. -- ` + "`hand status `" + ` shows a worker's reported state; see SPECS.md's state management section for the report vocabulary (working/paused/blocked/needs-decision/done/failed). +- ` + "`hand status `" + ` shows a worker's reported state. Workers report with ` + "`working:`" + `, ` + "`paused:`" + `, ` + "`blocked:`" + `, ` + "`needs-decision:`" + `, ` + "`done:`" + `, or ` + "`failed:`" + `. ` // Refresh writes or refreshes dir/AGENTS.md and its CLAUDE.md symlink, reporting whether @@ -192,9 +191,8 @@ type Violation struct { Severity Severity } -// Check reports perishable content, an unterminated code fence, and generated-block drift or absence in -// dir's AGENTS.md without fixing any of it, so a human looks at the prose judgment a machine cannot make -// (SPECS.md's "hand doctor"). A nil result with no error is an absence - no fleet home, or no file. +// Check reports perishable content, malformed fences, and generated-block drift or absence without +// fixing any of it. A nil result with no error means no fleet home or no file. func Check(dir string) ([]Violation, error) { isHome, err := home.IsHome(dir) if err != nil { @@ -259,7 +257,7 @@ func Check(dir string) ([]Violation, error) { // Info rather than a failure: a file left marker-less by accident is indistinguishable from one // left that way on purpose (atqamz/secondhand#90). violations = append(violations, Violation{ - Text: "no hand:generated markers: hand init and hand update leave a marker-less file alone, so this template can never refresh itself here - paste the current generated block back in if that is unintended, or ignore this finding if the file is deliberately hand-authored (see SPECS.md's \"hand doctor\" section)", + Text: "no hand:generated markers: hand init and hand update leave a marker-less file alone, so this template can never refresh itself here - paste the current generated block back in if that is unintended, or ignore this finding if the file is deliberately hand-authored", Severity: SeverityInfo, }) case content[blockStart:blockEnd] != strings.TrimSuffix(generatedBlock(), "\n"): diff --git a/internal/agentsmd/agentsmd_test.go b/internal/agentsmd/agentsmd_test.go index 1fff80a..fc087ad 100644 --- a/internal/agentsmd/agentsmd_test.go +++ b/internal/agentsmd/agentsmd_test.go @@ -67,6 +67,31 @@ func TestRefreshWritesAgentsMdAndClaudeSymlinkWhenMissing(t *testing.T) { } } +func TestRefreshWritesReportVocabularyIntoFleetWorkflow(t *testing.T) { + dir := makeWorkspace(t) + if _, err := Refresh(dir); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "state/.status", + "working:", + "paused:", + "blocked:", + "needs-decision:", + "done:", + "failed:", + } { + if !strings.Contains(string(got), want) { + t.Fatalf("got generated AGENTS.md %q, want report contract %q", got, want) + } + } +} + // This is the requirement most likely to regress silently: a refresh must // never wipe out rules or sections the user appended by hand. func TestRefreshPreservesUserAddedContentAcrossRefresh(t *testing.T) { diff --git a/internal/axi/axi.go b/internal/axi/axi.go index d9246a8..cba8f53 100644 --- a/internal/axi/axi.go +++ b/internal/axi/axi.go @@ -1,6 +1,5 @@ // Package axi renders command output as TOON: scalar fields, count-prefixed -// row blocks with a schema header, and help[] next-step lines. SPECS.md's -// "Output shape" section owns the contract; https://axi.md owns the principles. +// row blocks with a schema header, and help[] next-step lines. package axi import ( diff --git a/internal/faketool/FIDELITY.md b/internal/faketool/FIDELITY.md index 355cec4..c58a49b 100644 --- a/internal/faketool/FIDELITY.md +++ b/internal/faketool/FIDELITY.md @@ -15,8 +15,8 @@ It covers no call that would change anything an operator owns: a scratch treehou Decorative glyphs appear in several stderr lines below and are omitted from the transcripts. No matcher may depend on one. -The rule these records serve is SPECS.md's "Testing strategy": a fake that answers a state-changing command identically before and after that command cannot test anything about the state change. -So each record notes what the call leaves behind, not only what it prints. +A fake that answers a state-changing command identically before and after that command cannot test anything about the state change. +Each record therefore notes what the call leaves behind, not only what it prints. ## treehouse diff --git a/internal/harness/harness.go b/internal/harness/harness.go index 44784eb..75c9d84 100644 --- a/internal/harness/harness.go +++ b/internal/harness/harness.go @@ -57,7 +57,7 @@ type FirstRunPrompts struct { // alone even if it parks on a dialog. var firstRunPrompts = map[string]FirstRunPrompts{ // Interactive claude gates on first-run dialogs --print skipped, and a fresh worktree path means - // the trust one appears on every spawn, not just a fresh host (SPECS.md, launch templates). + // the trust one appears on every spawn, not just a fresh host. // cmd/launch.go's confirmLaunch clears them per spawn, not leaving them for an operator to notice. Claude: { // claude's own startup paint: the splash banner, or either composer footer hint once the REPL diff --git a/internal/herdr/types.go b/internal/herdr/types.go index a195a02..cc49fb2 100644 --- a/internal/herdr/types.go +++ b/internal/herdr/types.go @@ -1,6 +1,5 @@ -// Package herdr wraps the herdr CLI's verified syntax for workspace, tab, and -// pane operations. client.go is the source of truth for that syntax, so -// SPECS.md's herdr examples should match this code, not the other way around. +// Package herdr wraps the herdr CLI's verified workspace, tab, and pane syntax. +// client.go and internal/faketool/FIDELITY.md own the calls and observed shapes. package herdr // Status is herdr's agent_status value for a pane. The real vocabulary has five values, not four: diff --git a/internal/project/gaterun.go b/internal/project/gaterun.go index 14194f6..4bf4e2b 100644 --- a/internal/project/gaterun.go +++ b/internal/project/gaterun.go @@ -40,7 +40,7 @@ func GateRunPRs(clonePath string) (map[string]bool, error) { } // Membership establishes only that the `pr` step opened that exact PR from a run that // reached completed, not a per-commit answer: no-mistakes keys its state on working_path - // (SPECS.md's "Gate preflight") and hand records no head commit to compare against. + // and hand records no head commit to compare against. prs := make(map[string]bool) for _, line := range strings.Split(text, "\n") { fields := strings.Fields(line) diff --git a/internal/project/project.go b/internal/project/project.go index c441811..d9752fc 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -1,6 +1,6 @@ // Package project manages the registry of git projects: a table in hand's // sqlite database, with data/projects.md kept in step as the human-readable -// projection. SPECS.md's "Which to believe when they disagree" covers a conflict. +// projection. The database remains authoritative if the two disagree. package project import ( diff --git a/internal/store/hold_test.go b/internal/store/hold_test.go index 20f39b5..2c79242 100644 --- a/internal/store/hold_test.go +++ b/internal/store/hold_test.go @@ -134,9 +134,8 @@ func TestListHoldsSortedAndEmpty(t *testing.T) { } } -// Pins the read side of the design decision in SPECS.md: a row an external write left -// inconsistent (here, an unrecognized kind) must still come back from ListHolds, or "what -// is held" silently drops exactly the row most worth seeing. +// An inconsistent row from an external write must still come back from ListHolds, +// or "what is held" silently drops the row most worth seeing. func TestListHoldsSurfacesEveryRowRegardlessOfKind(t *testing.T) { db, _ := openTemp(t) if err := db.SetHold(Hold{ID: "weird", Kind: "not-a-real-kind", Reason: "who knows"}); err != nil { diff --git a/internal/watcher/events.go b/internal/watcher/events.go index 856f43d..99c9859 100644 --- a/internal/watcher/events.go +++ b/internal/watcher/events.go @@ -56,9 +56,8 @@ func KnownKinds() []string { } } -// NotifyFilter is the EventFilter for the watcher's in-process notify hook - see SPECS.md's -// "Notifying a supervisory agent with no session watching" for why its membership differs from -// --event's. +// NotifyFilter is the fixed subset of events worth sending through the +// watcher's unattended notification channel. func NotifyFilter() EventFilter { // report-blocked is listed even though blocked already is: it is the worker's own report-channel // declaration that it is stuck, not the herdr transition, and ClassifyStatus suppresses @@ -177,9 +176,8 @@ func NewTaskState(status herdr.Status, now time.Time) *TaskState { return &TaskState{Status: status, Probed: true, ChangedAt: now} } -// ClassifyStatus compares a freshly probed status against ts and returns an actionable event for the -// transitions SPECS.md calls out (idle-unreported, blocked, failed). Benign transitions - into -// working, repeated not-busy or blocked - update ts in place and return nil. +// ClassifyStatus compares a fresh probe with tracked state. Actionable transitions return an event; +// working and repeated not-busy or blocked states update ts and return nil. func ClassifyStatus(ts *TaskState, id string, status herdr.Status, probeErr error, now time.Time) *Event { if probeErr != nil { wasProbed := ts.Probed diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 4570caa..57e216b 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -45,7 +45,7 @@ var ErrArmFailed = errors.New("could not arm") // Run blocks, polling herdr agent states at cfg.PollInterval until ctx is canceled, returning nil on // clean cancellation or an error if herdr is unreachable at startup. out receives the actionable -// event stream SPECS.md documents, errOut internal diagnostics, per the stdout/stderr split. +// event stream, while errOut receives internal diagnostics. func Run(ctx context.Context, cfg Config, out, errOut io.Writer) error { client, err := connect(ctx) if err != nil { @@ -258,9 +258,8 @@ func tick(ctx context.Context, cfg Config, client *herdr.Client, states map[stri } } -// Every fact restored here comes from durable state, never re-derived from current evidence: what -// landed while the watcher was down (hand merge writing merged, say) would otherwise look like an -// announcement that already went out. SPECS.md's "What survives a hand watch restart" owns the rest. +// Every restored fact comes from durable state: re-deriving what landed while the watcher was down +// would make new evidence look like an announcement that already went out. func resumeTaskState(t state.Task, status herdr.Status, now time.Time) *TaskState { changedAt := statusChangeSeed(t, status, now) ts := NewTaskState(status, changedAt) @@ -655,9 +654,8 @@ func handleEvent(cfg Config, e *Event, out, errOut io.Writer) { notifyEvent(cfg.Home, e, errOut) } -// NotifyFilter's own consumer of the classified event stream - see SPECS.md's "Notifying a -// supervisory agent with no session watching" for why an unconfigured config/notify stays silent -// while a failed send is loud. +// The unattended hook treats an absent config as disabled, while a configured +// channel that fails is a diagnostic worth surfacing. func notifyEvent(home string, e *Event, errOut io.Writer) { if !NotifyFilter().Matches(e.Kind) { return diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 0711ed8..cccd060 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -68,8 +68,7 @@ func Return(worktreePath string, force bool) error { } // CheckCollision cross-checks a freshly acquired lease against every other task's recorded one, -// returning the ID of the conflicting task or "" for no collision. SPECS.md's "Collision guard" -// owns the rest. +// returning the ID of the conflicting task or "" for no collision. func CheckCollision(homeDir string, lease Lease, excludeID string) (string, error) { tasks, err := state.List(homeDir) if err != nil { diff --git a/tests/e2e/send_test.go b/tests/e2e/send_test.go index 143d758..e105f92 100644 --- a/tests/e2e/send_test.go +++ b/tests/e2e/send_test.go @@ -85,7 +85,7 @@ func TestConcurrentSendsToTheSameTaskSerialize(t *testing.T) { // Covers the outcome an operator or a calling agent actually sees when a composer never frees: the // documented exit code off the real process, and a trace of the abandoned message that outlives the process -// that tried to send it (SPECS.md, `hand send`). +// that tried to send it. func TestSendRecordsAnUndeliveredSteerAndExitsSix(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") From 3f0afe62784a33a91ded743464911735e2ebf63c Mon Sep 17 00:00:00 2001 From: Atqa Munzir Date: Wed, 5 Aug 2026 09:44:09 +0700 Subject: [PATCH 2/2] no-mistakes(review): Remove obsolete SPECS citation --- internal/selfupdate/notice.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/selfupdate/notice.go b/internal/selfupdate/notice.go index 9ba32f4..9a49e14 100644 --- a/internal/selfupdate/notice.go +++ b/internal/selfupdate/notice.go @@ -22,7 +22,7 @@ type versionCache struct { // CheckNotice returns a one-line stderr notice when a newer hand release is available, or // "" when up to date or when the check can't be completed. Bounded by checkTimeout and -// never fails the caller: startup version checks are non-blocking and non-fatal per SPECS. +// never fails the caller: startup version checks are non-blocking and non-fatal. func CheckNotice(home, repo, currentVersion string) string { // A version that isn't semver (a build without ldflags, defaulting to "dev") has no // released version to compare against, so nagging a from-source build would be noise.