diff --git a/AGENTS.md b/AGENTS.md index 87ef61a..a38deca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,11 @@ `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 "AGENTS.md (target)" section for the full design, and `hand --help` for the command reference. +`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. ## 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c0afa4..1b3d457 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,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 SPECS.md, which is where it is read. +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. `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 fef1007..aed9d44 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,18 @@ Optional: - [no-mistakes](https://github.com/yes2games/no-mistakes) - validation pipeline for projects in `no-mistakes` mode - [qmd](https://github.com/tobi/qmd) - semantic search over historical task data, beyond `hand search`'s keyword matching +`hand` never installs or configures qmd, and every command works without it. +To point it at a fleet home's corpus by hand: + +```sh +qmd collection add data/ --name secondhand +qmd context add qmd://secondhand "Task briefs, scout reports, decisions, and backlog history" +qmd embed + +qmd search "login auth decision" --json +qmd vsearch "how did we handle the deploy failure" -c secondhand +``` + ## Installation From source: diff --git a/SPECS.md b/SPECS.md index 4ddea42..58eeae1 100644 --- a/SPECS.md +++ b/SPECS.md @@ -7,21 +7,17 @@ Language: Go Default backend: herdr No persona, no role-play. Pure functionality. -## Problem +## What this file is -Running one coding agent is easy. -Running three in parallel on different tasks across different projects turns you into a tab-juggler: babysitting sessions, copy-pasting context, forgetting which terminal had the failing test. +`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. -Firstmate solved this with an "agent distro" - a directory of instructions and shell scripts that turns a general-purpose agent into a fleet supervisor. -The concept works. The execution ballooned 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 - all in 6 days. +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. -Three fundamental problems emerged: - -1. **Session clobbering.** The supervisory agent's main session drowns in operational noise: bootstrap digests, hook injections, guard warnings, status polling, watcher rearms. The 21K-token always-loaded instruction file eats context every turn. Long sessions trigger malformed tool calls at ~500k context. The captain's chat becomes a system log instead of a command center. -2. **Shell brittleness.** macOS bash 3.2 causes silent failures in spawn and brief scaffolding. Locale inheritance breaks checksums and state reads. BSD vs GNU tool detection fails with mixed toolchains. Content-hashing terminal panes for state detection is fundamentally fragile. -3. **Self-imposed complexity traps.** Session locks deadlock recovery. Continuity hooks block the commands needed to fix the problems they detect. The watcher/guard/hook system creates more problems than it solves. - -Secondhand keeps the concept and rebuilds the execution as a single Go CLI binary. +Why: `docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md`. ## Core principles @@ -34,6 +30,8 @@ Secondhand keeps the concept and rebuilds the execution as a single Go CLI binar 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 ``` @@ -60,10 +58,9 @@ Secondhand keeps the concept and rebuilds the execution as a single Go CLI binar ``` The supervisory agent is any supported harness (claude, codex, pi, grok, opencode) launched inside a fleet home. -It reads AGENTS.md, understands the `hand` CLI, and manages the fleet. +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"). -Workers are autonomous agents launched by `hand spawn` into herdr tabs with treehouse worktrees. -They follow the brief, do the work, and report through herdr's agent state. +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 @@ -76,9 +73,6 @@ Nothing derives it from a file, and no view is assembled by re-reading a renderi **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`. -The boundary is the design, more than the engine is. -It is drawn where recovery cost differs: machine state is cheap to reconstruct and expensive to disagree about, while the corpus is the opposite. - 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. @@ -91,20 +85,19 @@ Three rules keep the derived half honest: 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.** -Not because the database is unreliable, but because of what the two failure modes cost. -The fleet has twice run a stale `hand` binary while every signal it produced read healthy, and both recoveries were `cat` on those status files. -A `.status` file is readable by `cat`, `tail -f`, an editor, and a human with no tooling at all; the database is readable by a working `hand`, which is the thing that was broken both times. -There is deliberately no `hand dump`: a dump command is one more thing that depends on the binary, and so is no help in the case that actually happens. +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"). -This keeps a fleet home a directory that can be copied, backed up and inspected with ordinary tools, which the whole design depends on. +A fleet home therefore stays a directory that can be copied, backed up and inspected with ordinary tools. ## Directory layout @@ -227,32 +220,33 @@ secondhand/ # maintainer's in-repo fleet home = repo checkout 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 already answer what the fleet is doing and what is waiting on a human, which is why `data/inbox.md` was proposed as that channel and cut (atqamz/secondhand#64), and why 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 (atqamz/secondhand#47); `hand update` seeds whichever of them the home is missing, so a home refreshed to a template that names them is never pointed at absent files (see "Self-update: `hand update`"). +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, and that one-way ownership is what lets its constraints outrank the agent's judgment at all. +`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 -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. `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. -`data/dashboard.md` was the file-based answer to the same need and was removed with atqamz/secondhand#62; -the hook replaces it with output generated at the moment it is read, which no file can be. +`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, which is what an install that -moved needs, and leaves any arguments the operator added to it alone. +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, because nothing there runs a supervising session. +directory at all. + +Why: `docs/adr/ambient-context-is-a-session-hook-not-a-file.md`. ## Output shape -`hand`'s only consumer is an LLM agent, so its default output is TOON (https://axi.md) rather than a table aligned for a human terminal (atqamz/secondhand#45). +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: @@ -261,7 +255,7 @@ Three block kinds, rendered in the order the command adds them: - 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 the silence a broken command also produces (atqamz/secondhand#100). +- 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. @@ -270,16 +264,18 @@ The rules that hold across every command: - 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. TOON is the default because the consumer is an agent; JSON stays because a caller that wants a parser-backed object should not have to parse TOON to build one. `--fields` narrows the TOON schema only, so combining it with `--json` is a usage error rather than a silently ignored request. +- `--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. -What a caller with nothing to go on needs first is the state; `hand --help` is one word away for the command reference. +`hand --help` is the command reference. ``` tool: hand @@ -300,7 +296,7 @@ help[3]: - 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 `~`, so a caller who has more than one `hand` on `PATH` can tell which one it reached. +`exec` names the executable that answered, with the user's home abbreviated to `~`. 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: @@ -316,15 +312,15 @@ help[2]: - Run `hand --help` for the command reference ``` -Exit `0`, unlike every other command's `3` for an unresolvable home: this is the one surface whose job is to introduce the tool, and a caller who has not set a home up yet is exactly who runs it. +Exit `0`, unlike every other command's `3` for an unresolvable home. ### `hand init [path] [flags]` 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 is how it picks up a file the layout gained since it was initialized, and never how it loses what is in one. -`hand update` seeds the same skeletons the same way, so an existing home picks a new layout file up whether it is re-initialized or updated. +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. @@ -388,8 +384,7 @@ Behavior: 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`, since treehouse leaves it untracked and - an untracked pool config makes the clone read dirty to every later `hand project sync`. + 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). @@ -449,15 +444,13 @@ Output (JSON): ``` Every column carries a value on every row: a project with no declared upstream reads `none` rather -than dropping the cell, so the row stays aligned with the schema header. JSON keeps its -omit-when-absent shape (`upstream`, `gate_issue`). +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, so a caller learns whether any project is -unspawnable without reading every row; a nonzero count adds the `help[]` line above saying where to -look. Every `no-mistakes`-mode project pays one `no-mistakes status` call per +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 @@ -480,16 +473,12 @@ 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: an unresolvable upstream would widen the PR guard to - whatever the comparison happened to fall through to. A slug containing whitespace is refused for a - second reason: the `data/projects.md` projection separates fields by whitespace, so a stored one - reads back truncated and rejects the whole registry line for every later project command. + 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. -This is a separate command rather than a `hand project add --upstream` flag because a fork project is -usually already registered by the time the first upstream contribution comes up, and `hand project -add` cannot be re-run against an existing clone. +Why: `docs/adr/a-fork-projects-upstream-is-declared-never-inferred.md`. Output: ``` @@ -558,8 +547,7 @@ Model and effort resolve most-specific-first: the flag, then the brief's `---` d 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, since consecutive warnings all naming -the same harness read as separate problems. What can be dropped: +launch drops is named on one line rather than one line each. What can be dropped: - a resolved effort under anything but claude (`harness.SupportsEffort`) - a resolved model under `codex`, `grok` or `pi` (`harness.SupportsModel`) @@ -570,10 +558,9 @@ The line reads `warning: harness "codex" cannot carry model "opus", effort "high operator-decision rule, the front-matter disclaimer; launching anyway`, listing only what that launch actually drops. -The warning belongs to a launch that is about to happen, so the tier resolves only after the -project, gate preflight and brief checks have passed. A run that refuses names its refusal and -nothing about a launch it never performs; `hand promote` resolves the tier in that same position -(atqamz/secondhand#156). +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. @@ -581,7 +568,7 @@ Behavior: 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: an `operator` or `blocked` hold outlives the teardown of the task it was set on, so reusing the id for new work would reattach the previous incarnation's open question to an unrelated task, and a `limit` hold means a worker is still on this id, merely out of quota. The error names `hand hold clear ` as the remedy. +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. @@ -589,9 +576,8 @@ Behavior: - 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 - it always creates a root tab and pane alongside it - so - this reuses that root tab as the task's tab (renamed to the task ID) instead of creating a - second one, which would leave the root tab behind as an orphan shell in the workspace. + 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. @@ -678,6 +664,8 @@ Task row written to the `task` table in `state/hand.db`, one column per field be } ``` +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]` @@ -700,15 +688,14 @@ Flags: 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 both are emitted as their own column: a worker that appends `paused:` while its harness keeps running used to render as a bare `working`, showing the pane and hiding the only party that had said why. A report file that exists but can't be read reads `unreadable` there, which is why that column is never the place the `unreported` flag lands - an I/O fault is not evidence the worker never reported. +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 deliberately not the task's age: the two used to be conflated, so a task spawned hours ago read as hours stale next to a status file its worker had touched minutes earlier - a reporting worker that looked abandoned. -`age` measures the task, `last_report` measures the channel. +It is not the task's age: `age` measures the task, `last_report` measures the channel. Output (fleet overview): ``` @@ -745,7 +732,7 @@ tasks[9]{id,pr,gate}: ... ``` -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`: an external write to `state/hand.db` is the only way such a row exists, since `hand hold set` validates before writing, and filtering it out here would silently drop the row most worth seeing. +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): ``` @@ -762,7 +749,15 @@ The `holds[N]` block still carries any open hold when there are no tasks at all 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 on the project's repo whose head ref is the task's current branch (never matched on title, issue number, or task id), and record it under the task if found - a no-mistakes gate's own `pr` step opens a PR directly, bypassing `hand pr`, so `pr` can go unrecorded for genuinely landed work. A project with a declared `upstream` searches that repo too, since a fork contribution's PR is opened on the upstream while the branch is pushed to the fork (atqamz/secondhand#134). Only PRs whose head branch lives in the project's own repo count there: an upstream carries head refs from every contributor's fork, and a head ref is matched on the branch name alone, so a stranger's same-named branch would otherwise be recorded as this task's PR. Every repo-slug comparison in this lookup folds case, since a GitHub slug is unique only up to casing: the head-repo filter matches what `gh` reports in GitHub's canonical casing against a slug derived from whatever casing the clone's `origin` remote carries, and an `upstream` naming the project's own repo in another casing is that same repo, so it is not searched a second time. Compared exactly, the first drops a landed PR and the second returns it twice, making it its own same-tier duplicate (atqamz/secondhand#146). A branch carrying several PRs resolves by preference tier - merged, then open, then closed-unmerged - and only when the winning tier holds exactly one PR: a tier with more than one match 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, so that mix refuses rather than resolving to the merged PR. Matches from both searched repos resolve 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 each candidate is named with its repo (`owner/repo#N`) rather than a bare number an operator would have to hunt for. A `scout` task is skipped: its deliverable is `data//report.md`, never a PR. This is a best-effort, non-blocking lookup (a held task lock, an unreachable `gh`, an ambiguous branch, or a task with no branch all just leave the command reporting what it read) so a fleet-wide `hand status` never pays this cost. +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. @@ -795,24 +790,25 @@ help[1]: - Run `hand send fix-login ` to answer this worker ``` -One item is not a list, so this view defaults to every field the fleet overview leaves out rather than to five of them; `--fields` narrows it the same way, drawing from the same vocabulary. +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`. -That is also why the merge state, the gate issue and the unacknowledged marker live in `flags` here exactly as they do in the fleet overview, rather than as suffixes on the fields they qualify. +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` is the worker's own last claim about itself, not something `hand` has verified - the same caution as the `done`-vs-`reported-done` distinction in `hand watch` - and so is every entry in `report_history`. -The field names carry that framing, which is why no invocation repeats it in prose. +`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`. -atqamz/secondhand#65: a worker's report prose has run several KB for a single task, and rendering it in full doubled the cost by repeating the latest entry - once as `report`, again as the last entry of `report_history`. Without `--full`: -- The `report` field and every history entry are capped to 200 runes (a character budget, not a word or line count, since the point is bounding rendered size). 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. `done: ` stays intact under this budget in the common case, since the worker convention puts the URL immediately after the prefix and 200 runes covers it comfortably; a URL buried after long prose is the same brief-authoring problem the write side already owns (see "Report channel"). +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`, so nothing is lost: the full text stays on disk and the path to it is one field away. It is emitted whether or not anything was cut, since a field that appears only when the content is long makes the schema a function of the content. -- The truncation adds a `help[]` line naming `hand status --full`, so the recovery is stated once more where a caller looks for what to run next. +- 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-#65 content: `report` and `report_history` untruncated, and the latest entry repeated in history. +`--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 { @@ -870,47 +866,32 @@ 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 (atqamz/secondhand#102): `hand send` waits for it to free rather than failing -immediately, so a caller does not have to reimplement that retry in shell. The wait is bounded, -not indefinite - a composer that stays busy for the whole bound means the message never reached -the pane, and that has to surface as a distinct, terminal-for-this-invocation outcome rather than -a hang. +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. A multi-paragraph steer going through the shell as a positional argument has - to survive quoting, embedded newlines, and backticks intact; `--file` is the way to send one - without gambling on that. Mutually exclusive with the positional `message` - exactly one of the - two is required. + 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. Long enough that an ordinary agent turn does - not need the caller to retry at all, short enough that an invocation still returns in bounded - time; a steer into an unusually long-running turn is a `--wait` argument, not a bigger polling - loop. + `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. A second `hand send` - against the same task waits behind the first rather than polling the same pane at the same - time - two unsynchronized retry loops racing the same busy composer is the exact hazard - atqamz/secondhand#102 traced a lost steer to. `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`). +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, so a steer that never arrives leaves a trace instead of - vanishing with the process that attempted it. Only the elapsed-`--wait` case exits distinctly - (see "Exit codes"); the two delivery failures are ordinary exit-1 errors. This lock is not held - for the wait itself: a `hand send` waiting out its bound must not stall `hand watch`'s polling - or a `hand status` read on the same task. + 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 send that reaches the pane moots it, - whether or not it is a retry of the exact message that was previously abandoned. A failure to - clear the trace warns on stderr and still succeeds: the message is already in the pane, so - failing here would invite a retry that double-sends the steer. + 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: ``` @@ -921,8 +902,7 @@ help[1]: - The pane has the message; run `hand status fix-login` to read what it does with it ``` -`chars` counts runes, not bytes, and is the one thing the caller cannot see for itself: what reached -the pane is the message as `hand` read it, `--file` or argument. +`chars` counts runes, not bytes, over the message as `hand` read it, `--file` or argument. Errors: - Task not found (exit 3). @@ -934,15 +914,17 @@ Errors: - 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: - this is a transient state a caller can retry (e.g. with a longer `--wait`), not a terminal - failure. The message is durably recorded as undelivered rather than lost; see step 5 above. +- 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 (atqamz/secondhand#63). See "Holds" under "State management" for the design this command exposes. +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" @@ -973,7 +955,7 @@ help[1]: Errors: - Invalid `--kind` (exit 2). -- `--kind limit` (exit 2). Refused with its own message rather than the generic one: the kind exists and an operator may well have seen one in `hand status`, so the error says who sets it and when it clears instead of claiming it is not a kind at all. +- `--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). @@ -1014,18 +996,20 @@ hand deliver no-mistakes-flake --reason "PR https://github.com/kunchenguid/no-mi hand deliver event-delivery-scout --reason "report at /home/atqa/secondhand/data/event-delivery-scout/report.md, no code to land" ``` -`hand teardown`'s landed-work guard asks "is this landed", and for a contribution offered to a repo the fleet does not control - a fork PR on an upstream, a deliverable that is a report rather than a commit - landing is someone else's decision, possibly never taken. That is a real terminal state the guard could not express, and `--force` is the wrong way to express it: it records the task as `torn-down`, indistinguishable from work abandoned unlanded. This command is that state, on the same pattern as the gate-opened-PR case (atqamz/secondhand#69): name the state the guard was missing rather than widen the guard. +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: nothing consumes the mark until teardown reads it, so the last word on what was delivered is the one worth keeping. +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 (atqamz/secondhand#129). +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 @@ -1055,26 +1039,28 @@ 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: if every uncommitted change is a tracked modification whose current content already matches the local default branch's tip byte-for-byte, the dirt is redundant with what already landed and teardown proceeds past it (atqamz/secondhand#79 - the no-mistakes gate's own review-fix round can leave a file edited but uncommitted in the worktree, and that edit sometimes reproduces content the gate's own merged fix already carries). 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 in the index, and that staged content is uncommitted work too. Untracked files are never safe - there is nothing in the base to compare them against - so their mere presence refuses regardless of what else is safe. Every failure to resolve, read, or parse fails closed into the refusal, so no dirt is ever discarded unverified. Resolution is local-only, no fetch: a stale local ref just means a real safe case is missed and falls through to the refusal below, never the reverse. When it does refuse, the error carries the worktree's `git status --porcelain` output so the operator can see what is dirty rather than deciding blind, capped at the first 20 entries plus a count of the rest (atqamz/secondhand#65 is the same lesson for report rendering). -2. If the task is recorded as delivered (`hand deliver`), stop here and treat the work as terminal: every remaining check below asks "did this land", which for a contribution offered to a repo the fleet does not control is not the fleet's question to answer. 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. +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: look for a PR on the project's repo, and on its declared `upstream` when it has one, whose head ref is the task's current branch, and record it under the task if found (same gate-opened-PR detection `hand status` performs, including the preference-tier rule for a branch with several PRs; see that command's spec). Detection failing because there is nothing to find (no clone on disk, `gh` unreachable) is not itself an error - it falls through to the same refusal below as if no PR existed. An ambiguous branch is different: teardown refuses outright rather than falling through to "no PR recorded" - that message means unlanded, and guessing which of an ambiguous set to trust is the failure this rule exists to remove. The refusal names every PR on the head ref with its repo and state, including one in a losing tier that did not itself trigger the refusal, since the operator has to resolve the whole branch, not just the pair that tripped the rule. A branch carrying both a merged PR and an open one is one such ambiguous case: the open PR is live evidence of unlanded work, so it is never silently resolved to the merged PR. + - 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. - This is atqamz/secondhand#129. `kind` is the one field `hand spawn` records that nothing can correct afterwards - `hand promote` only goes scout to ship - so a scout spawned without `--scout` arrives at teardown as a ship row whose shape (a report, no PR) is exactly what the landed-work guard refuses, and `--force` plus a respawn was the only way out: forcing past a work-may-not-be-landed guard to fix a metadata typo. The fix makes the guard right rather than escapable by reading the work instead of the record. - Both halves of the condition 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. "No PR and some file exists" would accept it and discard them. The branch comparison is local-only and fails closed exactly like step 1's, so an unresolvable base ref refuses rather than accepts. - It is decided here, last, so it can only answer the case nothing else claims: a delivery, a local-only merge, and a gate-opened PR all stop above it, so reaching it means no PR exists for it to shadow. Merge evidence excludes it outright for the same reason - `hand promote` keeps the report on disk while turning the row into a ship, so a promoted scout that then merged locally has every shape this case reads, and it landed as a merge. Recording it as a report would claim a merge never happened, the inverse of the atqamz/secondhand#78 accuracy rule. + - 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`: treehouse refuses to clean a dirty worktree without it and there is nothing here to answer its prompt, so an unforced return would either abort after the tab is already closed or hand the pool a slot that is still dirty. - 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 refused, never taken for a returned worktree. +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 - re-raising decisions already resolved, absorbing a genuine unexplained stop as one already seen, and auto-recording a PR URL out of the previous run's `done` line onto a task nobody recorded it for. The durable deliverables under `data//` survive teardown, as before; keeping a torn-down task's wake history would be its own feature with its own reason, not a side effect of cleanup. -A hold on the id is not removed either, deliberately - it is not task-scoped, so it outlives the row and keeps an unanswered question visible, 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, so left behind it would refuse `hand spawn` on that id forever. 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. +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). @@ -1086,28 +1072,28 @@ Behavior (scout task): 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`), so reaching for `--force` on it - and recording it as `torn-down`, indistinguishable from an abandoned task - is never the answer. +- 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 has to be runnable a second time: a resource already released is that step's goal already reached, not an error, and never something `--force` should be needed for. +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 - that is treehouse's own answer, since `treehouse return` on an already-returned path is a no-op success, and it is not 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 removals are ordered the same way - the report channel goes before the task's row, since the report removal is the one that can fail on a permissions or I/O fault and doing it first leaves the row, and with it the retry, intact. +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. That ordering has to hold under a fault on either side of it, and the two sides fail in different, deliberate directions: +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. Nothing was recorded, but the task is untouched too, so the whole command is simply retryable. -- If the state removal that follows it fails, the record already written is not thereby wrong, and it is already durable. Everything it claims (work landed, worktree returned) was independently true earlier in the same run regardless of what a later bookkeeping step does. The task's row is left in place, so the retry replays the whole command and appends a second, functionally duplicate record - a harmless duplicate traded for never silently losing a completion, on purpose. +- 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` exists as a sibling to `state/events.log` rather than a share of it: `events.log`'s writer reads the whole file, appends, and rewrites it via a temp-file rename, which is fine for its single long-lived writer (`hand watch`) but loses a line outright if a second process's read-modify-write race lands its rename over the first's. Teardown is a short-lived process that can genuinely overlap a running `hand watch`, so the completion store instead takes a dedicated lock and performs one `O_APPEND` write per record - no read, no rename, nothing for a second writer to clobber. +`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 deliberately uncapped: it is the only durable record of a task's completion once `hand teardown` removes the row, and `hand status` only ever shows the live fleet, never history - so keeping just the last N entries would throw away the answer to "what happened to a task that's gone" with nothing left to reconstruct it from. Each line is a complete JSON object (`id`, `project`, `kind`, `outcome`, `detail`, `torndown_at`), readable without parsing markdown. +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 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 the upstream maintainer then actually merged (`merged` or `pr_merged_observed` on the row) records `merged` instead, because that is the stronger of the two facts and the requirement is only that the record never claim a merge that did not happen. +- `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: @@ -1137,6 +1123,8 @@ Errors: - 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]` @@ -1217,13 +1205,13 @@ hand pr fix-login https://github.com/org/repo/pull/42 ``` Behavior: -1. Validate `` matches `https://github.com///pull/` exactly (anchored, no substring matching - a PR URL feeds `gh pr merge` and `gh pr view` downstream, so a loose match here is a command-injection-adjacent risk). +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 (the URL is already on record, so there is nothing left to validate or write). This reconciling repeat is why `hand pr ` is a sound remedy for a `pr-record-unknown` event: the lock holder that event leaves unnamed may have been recording this very URL, and a plain confirmation here lets the operator resolve the ambiguity instead of erroring on a URL that was already fine. -4. If the task already has a *different* PR recorded, refuse - one task, one PR; correcting a wrong record is a deliberate `hand teardown`/`hand spawn` decision, not something `hand pr` overwrites silently. -5. Resolve the task's project and derive `owner/repo` from the project clone's own `origin` remote (`git config --get remote.origin.url`, not `git remote get-url`, so a local `url..insteadOf` rewrite never turns a genuine mismatch into a false 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"). A fork contribution's PR lives on the upstream, not on the fork hand pushes to, so the upstream passes - but 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, so an operator can tell "wrong upstream" from "no upstream declared". Both comparisons fold case: a GitHub slug is unique only up to casing, so folding admits no foreign repo, while comparing exactly refuses a genuine PR whose URL carries GitHub's canonical casing when the clone's `origin` remote or the declared upstream was written in another (atqamz/secondhand#146). -7. Confirm the PR exists via `gh pr view` (network check, 30s timeout) - shape validation in step 1 only proves the URL looks right, not that the PR is real. The refusal names the repo the *URL* belongs to, not the project's own: step 6 accepts a PR on the declared upstream too, so naming the project repo here would send an operator whose upstream PR number is wrong to check a repository the URL was never on. +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`. @@ -1237,8 +1225,7 @@ help[1]: - Run `hand merge fix-login` once this PR's checks are green ``` -Output (reconciling repeat) - same document, `result: already-recorded`, so a caller reads one field -to tell the two apart rather than two sentences. +Output (reconciling repeat) - same document, `result: already-recorded`. Errors: - Malformed PR URL (usage error, code `2`). @@ -1249,6 +1236,8 @@ Errors: - 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]` @@ -1269,7 +1258,7 @@ 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, since it is the fleet's durable record and a caller narrowing its own wake has no bearing on what happened. 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. A caller filtering on the classification a line came from, not its printed spelling, is why the filter is caller-expressible at all rather than hardcoded to one split: `working` is exactly what distinguishes a wedged spawn from a slow one, so no fixed actionable/progress grouping could serve every caller. +- `--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: @@ -1279,14 +1268,14 @@ Behavior: 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 - no dwell, since a pane that was just fine is itself the evidence. 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 instead, measured against `config/stale-threshold`, before it fires: a pane can blink through a probe or two while herdr catches up to a just-spawned worker, and a blink must produce no event, only a pane that stays dark past the dwell earns one. Either path fires once per outage and stays quiet until the pane is next seen healthy. + - `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 itself has stopped growing for longer than its bound - independent of `stale`, which only ever watches herdr transitions and has nothing to fire on when herdr registers no transition at all (a pane can sit healthy and quiet forever without one). The bound is chosen by the last classified report line, and `done`/`failed` are bounded rather than exempt: the status file being torn down is what actually severs a task from steering, not the worker's own last word on the matter, so a done/failed worker still attached to a pane is silence like any other. Three tiers, three keys, rather than one tier reused for two of them: `paused` gets the long bound (`config/parked-paused-bound`, default 3600s) since naming what it's waiting on already explains the quiet; `done`/`failed` get their own bound (`config/parked-done-bound`, default 5400s), longer still, since a finished worker's pane lingering is expected and unhurried; everything else - `working`, `blocked`, `needs-decision`, or no report at all - gets the short one (`config/parked-other-bound`, default 1200s) since that silence is unexplained. Edge-triggered like every other trigger: fires once per silence episode and only refires once the report file actually grows past the mtime it fired for - a bound that holds across `hand watch` restarts, since the instant fired for is persisted as `parked_fired_for` rather than re-derived. A parked worker and a crashed one are indistinguishable from the status file alone - both are a report line that stopped moving - so the event carries only the last line and its age and leaves the process check itself to the caller (`hand status `, or the session directly). + - `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 `` is the underlying error, which is what says which. The whole cause is kept; only its line breaks are not, since `gh`'s multi-line stderr reaches these errors verbatim and an event is one line on stdout and one entry in `state/events.log` (continuation lines would parse back as separate events in both). The fix in every case is a human running `hand pr `: it either records the URL or fails with the real underlying reason. The kind is deliberately not split by cause, since an enumeration of causes is forgotten the next time a new one appears. + - `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. @@ -1298,116 +1287,80 @@ Behavior: 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, because that lock is held across unbounded network and git work (`hand merge` across `gh pr checks`/`gh pr merge`, `hand promote` across a `git push`): waiting on it can stall every other task indefinitely, and `flock` cannot honor a SIGINT/SIGTERM in the meantime. 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. A marker persisted ahead of its line would, if the process died in between, suppress an announcement nothing can re-derive; a duplicate line is the cheaper failure. +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. -Two watchers on one home are not a redundant pair: each polls herdr independently, each classifies the same transition, and each fires `hand notify`, so the fleet's news arrives twice and the notify hook - whose whole purpose is to reach an operator with no session watching (see "Notifying a supervisory agent with no session watching") - becomes the loudest duplicate of all. -Ownership is validated at the point of acquisition, in the tool, because the way a second watcher actually gets started is a session that lost the memory of having started the first one; a convention written down anywhere is a convention that compaction can drop. **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 and no liveness heuristic can decide wrongly. -This is the whole reason the mechanism is safe to have at all: a lock that a crash can leave held would lock a fleet home out of watching itself, which is worse than having no lock. +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: two watchers is the condition being prevented, so a takeover that cannot confirm the incumbent is gone must not become one. -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 - correctly, since 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. +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 -Detecting an event and delivering it are separate problems, and the streaming mode solves only the first. -`hand watch` never exits, and 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 is read only if the agent independently decides to look. -"Remember to check the watcher" is not a mechanism, and it is what failed on 2026-07-28. +`--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: -`--until-event` makes the process exit the delivery: - -1. Arm: connect to herdr and probe every active task's pane once, before anything else - both bounded by `--timeout` so a wedged herdr daemon cannot strand the wait past it before the poll loop even starts. Any task that *fails* its probe names it on stderr and exits `5` - never enters the baseline half-armed, since a task invisible to the very first probe has no herdr transition to ever fire on. Losing the race against `--timeout` instead is exit `4`, not `5`: the wait window is simply over, exactly as it is for a timeout in the poll loop, and no single task can be named as the cause the way `5` promises. 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 and those lines are new to the poll loop. 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). +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`. -Each rule below closes one way the `tee` + `grep -m1` wrapper this replaces failed: +Four rules govern the wait: -- **The startup state is never an event.** Only a change from the baseline exits. A worker that was already `done` when the watcher armed produces nothing on stdout, where the wrapper's `grep` matched it, exited, and left the pipeline half-alive with nobody reading the two real events that followed. -- **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 - which is how the wrapper came to exclude the exact signal it was built for. -- **Arming itself can fail loudly, distinct from every other exit.** A worker whose pane answers with a failure at arm time is not a quiet fleet (`4`) and not a delivered event (`0`) - it is `5`, naming the worker, because the caller would otherwise wait out the full `--timeout` for a cause it can never see on stdout. An arm probe that instead runs past `--timeout` names no worker and is `4`. +- **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. -- **No pipeline for the caller to get wrong.** The exit is the whole mechanism. -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 itself 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 alone, but never past that per-task cap times the count. Once the event is written to stdout, the process exits immediately; nothing after that adds delay. +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 and silently dropping a state change is worse than repeating one. +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. -One invocation delivers one wake, and re-arming is the caller's own next step after acting on the exit, which it takes anyway with no human in it. - -This covers the awake path only: an exit reaches a session that exists and re-arms, and has no reach when no session -is running. `hand notify` (see its own section) is the channel that reaches an unattended fleet, and every `hand -watch` invocation - `Run` and `RunUntilEvent` alike - now calls it in-process for every event that matches -`NotifyFilter` (see below), whether or not that event happened to also be printed to stdout, so a transition -discovered on a restart's own baseline tick still reaches the operator the same way a live one would. +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` - -the identical `EventFilter`/`Matches` mechanism `--event` uses - with its own fixed membership, and `handleEvent` -checks it the same way it checks `cfg.EventFilter`. Its membership names the event kinds worth waking someone for -with no session watching: `blocked`, `report-blocked`, `failed`, `report-failed`, `report-needs-decision`, -`report-done`, and `usage-limit-stuck`. `report-blocked` - the worker's own `blocked: ` report line, per "Report channel" - 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` and the `pr-record-*` kinds are left out - each describes a -transition the poll loop is already tracking toward one of the six above, or one that resolves on its own without a -human, so notifying on it too would either double up the same fact or wake someone for nothing actionable. -`usage-limit` and `usage-limit-resumed` are left out for that second reason exactly: a limit that the mechanism is -already scheduled to resume from, and a worker that is running again, are both bookkeeping a human has no part in. -`usage-limit-stuck` is in the set because it is the one of the three that says the mechanism has run out of its own -answers (see "Resuming a usage-limited worker"). - -`handleEvent` calls `internal/notify.Send` directly for every event `NotifyFilter` matches, never by shelling out to -the `hand notify` subcommand, so wiring reaches every caller of `hand watch` with no shell wrapper required. An unconfigured -`config/notify` is expected on most fleets - the same silent fallback every other `config/` default gets - so it -produces no diagnostic; a *configured* template that fails is written to the watcher's own stderr, the same channel -every other watcher-internal failure (a stuck lock, an unreadable report) already uses, per "Error output". -That rule is the whole consequence of a failed send: a diagnostic is written and the poll loop carries on, so the send -never ends the run nor suppresses the event's own stdout line. -A template that *hangs* is bounded the same way, by a timeout inside `internal/notify.Send` that surfaces as one more -such diagnostic - the send runs inline in the poll loop, so an unbounded one would wedge polling, `--until-event`'s -`--timeout` and shutdown alike. - -One notifiable kind, `failed` fired from an already-unreachable pane (`ClassifyUnreachable`), can re-fire once per -`hand watch` restart for a condition that was already true before the restart, because its firing latch -(`UnreachableFired`) is deliberately non-persisted (see "What survives a `hand watch` restart" below). This is the -same "duplicate over silence" tradeoff the poll loop already accepts for that latch - a caller re-arming -`--until-event` gets a repeat notification for a condition that has not changed, rather than one that silently stops -notifying because the process restarted - so no additional rate limiting is added on top of it. `parked` does not -make the same tradeoff: its latch is persisted as `parked_fired_for`, because a done or failed task's report file -never grows again and the duplicates evicted real history from the capped `state/events.log` -(atqamz/secondhand#127). It is not a notifiable kind either way. +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. -#### Resuming a usage-limited worker +`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. -A worker whose harness runs out of quota stops mid-task with the reason on screen and nothing else (atqamz/secondhand#136). -The stop looks like any other stop to the rest of the poll loop, so without this the task sits dead until a human notices. -Detection, a recorded state, and a resume trigger are one mechanism; any one of them alone leaves the task exactly as dead with a better label on it. +#### 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: 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. This is the shape atqamz/secondhand#81, #84 and #85 got wrong by growing the poll loop one conditional at a time, and #128 was the bill for it. +**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` sit alongside `report_offset` and `parked_fired_for` as watcher bookkeeping: 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. Neither substitutes for the other - a hold carries no schedule, and a column is not rendered anywhere an operator looks - and the schedule is the authority: the retry path reads the columns, never the hold. +**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.** No attempt waits out a timestamp and then declares the quota back. An attempt is the same two-call steer `hand send` performs - a plain instruction into the composer, then Enter - and what it produces is either a pane that starts working (the next tick's clear check sees that and fires `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 ever decides when to start trying, and it is read from the freshest refusal on screen: an older one still in scrollback names a reset that has already come and gone. +**"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. -An operator send landing in that window is in any case the likelier thing to end the limit. **The failure mode designed against is a retry storm against an account that is still limited**, and five things bound it: @@ -1417,79 +1370,72 @@ An operator send landing in that window is in any case the likelier thing to end - 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 is the case no transition can cover, since the stop that stranded the worker happened before this watcher existed. A worker whose report channel already says `done` or `failed` is left alone entirely: it is not waiting on quota, and steering it would restart work that is over. Recognition matches on the quota being *reached*, so the harness's own "approaching your limit" warning is not a stop. +**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.** Re-deriving is how an announcement gets silently skipped: evidence that lands while the watcher is down makes the restarted process conclude the line already went out. Every fact the poll loop carries across a restart, and which side of that rule it is on: +**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. The digest fingerprints the bytes the offset covers, and the pair is what the reader trusts or discards as one value - an offset alone cannot say whether it still describes the file it was taken from (see "Report channel"). | +| 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. `hand merge`'s evidence can land while the watcher is down. | -| An auto-recorded PR URL | Persisted as `pr` on the task, and every outcome that isn't a 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 same tick that consumed the line. Re-reading them from `state/.status` on resume instead - the previous behavior - meant re-reading history the durable offset says has already been consumed, and re-derived them from a file `hand promote` deliberately leaves alone, so a promoted ship inherited the scout's last report as if it were its own. They explain a quiet pane (`parked`'s bound is selected by the state, and `hand status` renders the note) 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. Inheriting it would suppress the new task's verified `done` forever, since the bookkeeping write-back stamps that inherited `done_verified` onto the fresh row, and would absorb its first unexplained stop. Same hazard as a surviving report channel, one layer in (see `hand teardown`). | -| The same bookkeeping across `hand promote` | Not covered by the identity check above, and the sharper problem: promote rewrites the task row in place, keeps `created_at`, and gives the task a *new herdr pane*. Every cached fact anchored to the old pane is therefore not evidence about the ship at all, however plausible it looks. See "Pane-anchored facts across `hand promote`" below for the field-by-field classification and for how the watcher forgets its cached copies. | -| Current herdr agent status, and the blocked flag derived from it | Re-derived, safely: a live pane property with no durable answer, seeded on first sight without emitting (transitions, not states, are events). A transition that happened while the watcher was down is not announced, but is not lost either: `hand status` shows a quiet pane through its `reported` and `flags` columns, read off the same report channel, and the stale timer below re-flags the task within one window. | -| Whether the last probe of the pane succeeded | Re-derived, safely: seeded from the probe that does the sighting, so it is true only once a probe has actually succeeded, and false whenever none has yet - a fresh spawn, a live `hand promote` (see below), or a resume whose own first probe fails. It gates the once-only `failed` latch and `stale`'s detection. False is the honest seed for both, because there is no true-to-false edge to announce when nothing was ever reached: a pane still unreachable at the sighting is announced by the dwell in the row below rather than on sight, so a blink at exactly the wrong moment produces no event at all. | -| How long the current herdr status has been dwelt in | Persisted as `status_changed_at`, updated whenever a herdr status transition is actually observed - any transition, not only ones that raise an event - and re-seeded from `created_at` until the first one. This is what `stale`'s dwell is measured against. Seeding it from the resume time instead - the previous behavior - erased a real dwell on every restart, and since `--until-event` restarts on every delivered event by design, a fleet busy enough to re-arm faster than the threshold elapses could erase that dwell before it ever completed once, silencing `stale` for exactly the fleet it exists to watch (issue #75's Ruling 1). | -| Which status that dwell clock describes | Persisted alongside it as `status_changed_for`, and the timestamp is trusted only while the two agree. A timestamp on its own cannot prove the dwell it describes is still running: a status observed in a different pane is a new dwell even when it spells the same word, so a mismatch means the transition into the observed status happened at an unknown point since and the dwell can only honestly start now. Without this a restart after a `hand promote` read the restamped `status_changed_at` as the ship's own dwell in whatever status the ship happened to be in. | -| The stale timer's fired latch, as opposed to its dwell above | Re-derived, safely: cleared on every observed transition and reset to unfired on resume, so the worst a restart causes is one duplicate `stale ` after a further full threshold past a dwell that already fired once - it never suppresses a genuine re-announcement. The dwell it is measured against is not re-derived; see the row above. A restart is the safe direction, but a `hand promote` in a *live* watcher is not, and the latch is cleared there explicitly; see below. | -| Which silence episode `parked` already fired for | Persisted as `parked_fired_for`, written after the `parked` line is announced. It is the one fired latch that is *not* re-derived, because what makes re-deriving the `stale` and outage latches safe does not hold here: those dwell clocks keep moving, so a restart costs one duplicate and then the condition has to genuinely re-mature. A done or failed task's report file never grows again, so its silence instant is frozen and every restart re-fires against that same instant - and `state/events.log` is capped at 200 lines, so the duplicates evict real history rather than merely repeating themselves (atqamz/secondhand#127). Stored to nanosecond precision, because the value is a report file's mtime compared for exact equality and whole seconds would round it into an instant no later mtime ever matches. An unparseable stamp resumes unfired: one duplicate is the failure direction this whole table prefers. What the bound is measured against - the report file's own mtime, floored at `pane_started_at` - is untouched by a restart because neither one is process state to begin with; see "Delivering an event to a supervisory agent". | -| 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"). Not re-derived, and for a sharper reason than `parked_fired_for`: a re-derived schedule would let every watcher restart attempt a resume immediately, against an account the last attempt just found still limited, which is the retry storm the bounds exist to prevent. The attempt count is durable with it, since it is what the backoff and the stuck bound are both measured in. An unparseable stamp resumes *unlimited* - the one place this table prefers silence to a duplicate, because the duplicate here is a steer sent into a live pane rather than a line on stdout. | -| That this watcher has already looked for a limit on this task | Re-derived, deliberately: `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. There is no transition left to detect on in that case, so the first sighting has to do it. It costs one pane read per task per watcher lifetime for tasks that are stopped and unexplained. | -| The first-sighting outage's fired latch, as opposed to its dwell | Re-derived, safely, the same way the stale latch above is: `UnreachableFired` is not persisted and starts unfired on resume. The dwell it guards is not re-derived from scratch, though - 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", so a restart mid-outage resumes the outage's true start rather than a fresh clock, and the worst a restart causes is one duplicate `failed ` past a dwell that already fired once. | - -Anything added to `TaskState` belongs in this table before it ships. +| 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**. -Every cached fact anchored to the old pane is invalidated at that moment, so the governing question for each one is not "is it durable" but "was it anchored to the pane". -Both halves have to be dealt with, because neither is sufficient alone: promote clears the durable fields itself rather than leaving it to `hand watch`, since a watcher may not be running at all; and a watcher that *is* running holds an in-memory `TaskState` that passes the `created_at` identity check untouched and would write its cached copy straight back onto the freshly-rewritten row on the very next tick. -`forgetPaneScopedCache` is the single place that drops those cached copies. +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 | Why it is not evidence about the ship | +| Fact | Reset to | |---|---| -| `done_verified` | The marker belongs to the scout's own verified `done`. The ship has not earned one, and carrying it would leave the ship run unable to ever announce its own, since the write-back only ORs the marker to true. | -| `status_changed_at` / `status_changed_for` | The scout's last observed transition happened in a pane the task no longer has. Carrying it would hand the ship a dwell already grown past `stale`'s threshold before its worker had run for a second. Promote restamps the timestamp to the promotion time and clears the status it was stamped for, which is what makes the ship's first observed status a fresh dwell rather than a resumed one. | -| `pane_started_at` | The whole point of the field: the ship's pane started at the promotion, not when the scout's did. Promote restamps it to the same instant it restamps `status_changed_at`, but it is 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` | The scout's last report describes work in that pane. It selects `parked`'s bound and feeds the scout's deferred-`done` bookkeeping, so an inherited one both mis-bounds the ship's silence and can hand it a `done` it never reported. | -| `delivered_at` / `delivered_reason` | The delivery described the scout's deliverable, a report, and not a line of the ship's code. Carried, it would let `hand teardown` accept the ship task as terminal with no PR recorded and no merge check run at all - the landed-work guard bypassed without `--force` for work nobody delivered, which is the one meaning `--force` keeps. | -| The `stale` and `blocked` fired latches | Each is what makes its announcement fire only once. A latch surviving the promote silences that announcement for the ship's own pane - the `stale` one until the ship transitions at least once, which a genuinely stuck ship never does. | -| Whether the last probe of the pane succeeded | It gates the once-only `failed` latch and gates `stale` detection off entirely until some probe succeeds, and either value inherited from the scout's pane is a claim about a pane the task no longer has. It is reset to false, matching the seed a fresh spawn gets before its own first probe has succeeded: the ship's first probe of its new pane is a first sighting, so an unreachable one is announced through that sighting's dwell rather than firing `failed` on sight. Resetting it to true instead - the previous behavior - fired a no-dwell `failed` off a single blink, on the strength of a probe that only ever described the scout's pane. | -| The first-sighting outage's fired latch | A latch claiming the scout's outage has nothing to say about the ship's new pane. Left true it would sit inert until the next probe failure reset it anyway, but a fresh pane deserves a fresh episode on purpose, not by accident of that ordering, so it is reset to unfired alongside the probe-succeeded flag above. | -| The usage-limit schedule (`usage_limit_retry_at`, `usage_limit_attempts`) and the `limit` hold | A usage limit belongs to the harness process that hit it, and the ship's pane runs a new one against whatever quota exists now. Carried, the schedule would steer the fresh pane on a clock the scout's refusal set, and the stale hold would keep saying the ship is out of quota. Promote clears both columns with the other pane-scoped fields and releases a `limit` hold on the id - only that kind, so an operator's own hold on the same id is left standing (see "Holds"). | -| The cached herdr status the next probe is diffed against | The status a transition is measured *from*, so an inherited one invents or erases transitions in both directions: a scout cached as `working` turns the ship's first not-busy probe into `idle-unreported` for a pane never observed working, and a scout cached as `blocked` makes the ship's own `blocked` probe compare equal and never fire at all. It is reset to herdr's `unknown`, which matches neither branch, so the ship's first probe is the baseline a first sighting always is. This is not self-correcting in the same-status case, as was once assumed: equality is exactly what suppresses the announcement. | +| `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 report stream is continuous across the promotion and the offset already points exactly where the ship's first line lands, with the digest still describing the bytes behind it. Resetting them would replay the scout's consumed lines, the hazard the durable offset exists to prevent. | +| `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 - this is one task's lifecycle, not two. | -| The `parked` fired latch (`parked_fired_for`) | Keyed to the report mtime it fired for, not to a pane, and the report channel is itself carried. Promote restamps `pane_started_at`, so the ship's evidence time is floored at the promotion instant - strictly later than anything the scout's silence could have fired against - and the latch cannot suppress the ship's own episode. | -| The report mtime `parked` measures silence from | Carried with the report channel, but *floored* at the pane-start instant `pane_started_at` records (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 - the value the floor already computed for it, so the migration neither slides a live task's floor nor hands a task promoted before it the scout's creation instant). Promote leaves the scout's last append - and so its mtime - untouched while clearing the `last_report_state` that had the scout's silence bounded by the long done/failed tier, so an unfloored mtime would hand a pane seconds old the scout's whole accumulated silence, now measured against the short bound, and fire `parked` on it immediately. | - -**The floor reads `pane_started_at`, never `status_changed_at`.** -The two answer different questions and cannot share a field. -A watcher tracking an unreachable pane persists `status_changed_for = unknown` with `status_changed_at` restamped to the outage-detection time, so a floor reading that field let an outage - or a blink that raises no event at all - slide it forward and forget up to a full bound of real report silence. -Reading `created_at` instead is not the remedy either: after a promote `created_at` is the *scout's* creation, which reopens exactly the hazard the floor exists to close. -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 trying to answer "when was the last herdr transition seen" (atqamz/secondhand#128). +| `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. | -Two properties of the forget rule are load-bearing: +**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 trigger is the task's herdr pane id differing from the one the cache was built against, not a status or a timestamp.** A ship whose first probe happens to read the same status the scout last held raises no transition at all, so a status-conditioned rule misses it. A timestamp-conditioned rule is wrong in both directions: `status_changed_at` is legitimately reseeded to "now" on a resume that finds the observed status no longer matching `status_changed_for`, which is not a promote, and RFC3339 is second-granular, so a real restamp landing inside the same second as this watcher's own last write is invisible. Only the pane id changes exactly when, and only when, the pane changes. -- **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 but before that tick's write-back; writing the cached values back there would erase the restamp, and since that write-back also advances `report_offset`, the report line the tick had already consumed and cached would be lost with no way to re-derive it. - -Forgetting the cached status is what makes a ship's first probe a baseline in the same sense a cold start's is - transitions, not states, are events - and a promote is a first sighting in every sense that matters, even though the task id and its tracking state are reused. +**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): ``` @@ -1504,7 +1450,7 @@ 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 (see "Delivering an event to a supervisory agent"). +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. @@ -1513,7 +1459,9 @@ Errors: - 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: a task invisible to the arm-time probe would never enter `states` and so could never produce the transition the caller is blocking on, silently degrading the wait into a guaranteed timeout. A task first sighted after arming is not this case at all - it enters `states` through the live poll's own first-sighting tracking, dwell and all; see the `failed` bullet above. +- `--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`. --- @@ -1529,8 +1477,7 @@ 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, which repairs a clone - registered before `hand project add` started excluding it: without that, its untracked pool + - 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. @@ -1547,11 +1494,9 @@ projects[3]{name,result,detail}: no-mistakes,up-to-date,none ``` -`advanced` counts the clones that actually moved, so a caller learns whether the sync changed -anything without reading every row. 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 so the row count is never -read as the whole story. +`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. --- @@ -1572,9 +1517,8 @@ Flags: - `--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, so a scout brief that declared a tier keeps it through promotion -unless the brief or a flag says otherwise. +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). @@ -1588,9 +1532,8 @@ Behavior: 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 on purpose: the same rollback contract as `hand spawn` applies up -to step 7, so a promotion that fails partway still leaves the scout's pane and worktree intact -instead of stranding the task with nothing to look at. +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: ``` @@ -1615,6 +1558,8 @@ Errors: `--skip-gate-check`. - Worktree or herdr errors (same as `hand spawn`). +Why: `docs/adr/the-watcher-persists-what-it-announces.md`. + --- ### `hand notify ` @@ -1633,8 +1578,7 @@ Behavior: 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`: the send is not held open for a - grandchild that outlives it, and its eventual outcome is not observable here either way. + backgrounds its work (`... &`) counts as succeeded once its own process exits `0`. Example `config/notify`: ``` @@ -1646,10 +1590,9 @@ Or for macOS: osascript -e "display notification \"$HAND_MESSAGE\" with title \"secondhand\"" ``` -`hand init --setup` still does not write `config/notify` - it covers `harness`, `model`, and `effort` only - so a fresh -fleet home leaves the channel unconfigured until an operator adds the file by hand. That absence is expected and quiet -in the watcher's hook (see "Notifying a supervisory agent with no session watching"); `hand notify` itself is the one -place it is loud, per the exit code below. +`hand init --setup` does not write `config/notify` - it covers `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: ``` @@ -1658,13 +1601,11 @@ message: fix-login PR is ready for review ``` Errors: -- `config/notify` absent or empty, or its command failed or timed out - all exit `1`. Earlier, an absent config printed - the `notified:` line and exited `0` regardless, so "not configured" and "delivered" were the same observable outcome: - the one path meant to reach an operator with no session watching could report a delivery it never made. An empty file - is the same case wearing a different shape - `touch config/notify` or a truncated one would otherwise run `sh -c ""`, - succeed, and claim a delivery just as wrongly - so it is treated as unconfigured, not as a template. All of these mean - the same thing to a caller - nothing reached the channel - so all are the same general error rather than a warning - behind exit `0`. +- `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`. --- @@ -1696,20 +1637,20 @@ help[1]: ``` Behavior: -1. Scan `data/` for markdown files, comparing each against the index by mtime and size, and index what changed. Refreshing on every query rather than on a schedule keeps every other command free of the index entirely: the index is derived, so a stale answer is always settled by the corpus, and nothing else in `hand` has to know the index exists. +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`). Silence is what a search that never ran also produces, so the zero is stated rather than implied. -4. A result that came in exactly at `--limit` says so in `help[]`, naming the doubled limit that would widen it: the capped rows and a corpus that genuinely holds no more are identical from the outside. +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 the literal text it looks like rather than parsed as query operators. +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 (atqamz/secondhand#62): a home initialized before the dashboard was deleted keeps its last render on disk indefinitely, and no command refreshes it, so indexing it would answer a prose search out of a frozen snapshot of removed functionality. +`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). @@ -1729,13 +1670,13 @@ 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 (see "AGENTS.md (target)"), and flag: +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 - the same `generatedBlockSpan` result `agentsmd.mergeGenerated` uses to decide whether to touch the file at all, so this finding states exactly the fact a refresh already acts on: 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. + - 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: @@ -1751,60 +1692,29 @@ help[2]: - 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, so a reader learns which -verdict they got without classifying rows themselves. A whole-file finding has no line to anchor to -and reads `none` there rather than `0`, which would read as line one. +`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[]`: there is nothing -to act on, and silence would be indistinguishable from a checker that never ran. 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 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. -The missing-markers finding stays informational rather than becoming a violation because `hand doctor` cannot resolve it into a pass/fail verdict on its own: a maintainer who has deliberately kept a file marker-less - this repo's own checkout being the example, see "AGENTS.md (target)" - sees that finding on every `hand doctor` run in that home, permanently, but the command still exits `0` there, so a red `hand doctor` keeps meaning something. A maintainer who left a file marker-less by accident sees the same finding and reads it as a nudge to paste the current generated block back in, not as a command failure to chase down. +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 date or self-expiring phrase inside inline code (`` `...` ``) or a URL is not flagged: a changelog entry or an example command legitimately names a date or says "awaiting #12" without going stale, 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` finds nothing to flag and reports its zero count and exits `0`, leaving `hand init` to be the one place that complains about an incomplete fleet home. +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 -`data/` grows large over time (400+ files, 4MB+ in real usage - briefs, reports, decisions accumulate). -`hand status` solves "what's happening now". -For "what did we decide about X three months ago", searching hundreds of markdown files by hand is tedious. - -`hand search` covers the keyword half of that in the binary, with no dependency and no setup (see its command section). - -[qmd](https://github.com/tobi/qmd) is a local search engine for markdown that adds what `hand search` deliberately does not do: semantic and hybrid search over embeddings. -Secondhand recommends it but does not require it: - -- `hand init --setup` does not require or configure qmd. -- If qmd is available, the agent can create a collection pointing at `data/` manually. -- The AGENTS.md sketch mentions `qmd search` as a way to find historical context, alongside `hand search`. -- All `hand` operations work without qmd. The agent can always fall back to `hand search`, or to reading files directly. - -```sh -# user installs qmd separately -npm install -g @tobilu/qmd # or: nix shell nixpkgs#qmd - -# secondhand suggests indexing -qmd collection add data/ --name secondhand -qmd context add qmd://secondhand "Task briefs, scout reports, decisions, and backlog history" -qmd embed - -# agent searches historical context -qmd search "login auth decision" --json -qmd vsearch "how did we handle the deploy failure" -c secondhand -``` - -qmd is not a dependency. It's a recommendation for users with growing data/ directories. -The agent should use it when available and read files directly when not. +[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 --setup` does 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 @@ -1827,7 +1737,7 @@ cd && CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously ``` The brief path is included in the prompt because Claude Code takes prompt text, not a file path. -`` is `agentsmd.OperatorDecisionRule` verbatim (see "AGENTS.md (target)"), appended to every prompt-carrying template because the worktree is outside the fleet home and the worker never reads the home's `AGENTS.md`. +`` 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 is the only harness with an effort flag: `opencode` takes `--model` but no effort, and `codex`, `grok` and `pi` take neither (`harness.SupportsEffort`, `harness.SupportsModel`). @@ -1841,65 +1751,32 @@ permission dialog. 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: - -- The workspace trust dialog. Claude Code only trusts a directory whose path or one of its - ancestors has been accepted before, and every treehouse worktree is a fresh path under the - pool root (`~/.treehouse/...`), so this dialog appears on every spawn, not just a fresh host. -- The bypass-permissions disclaimer, a one-time global accept that `--dangerously-skip-permissions` - is gated on. -- The managed-settings security dialog ("Managed settings require approval"), shown when this - host has managed settings configured by the organization's IT administration. It has nothing - to do with the checked-out repository: accepting it grants arbitrary code execution and prompt - interception for every run on the host, so it is recognized but deliberately not answered. The - operator accepts it once on the host, then respawns. - -Their signatures (`internal/harness`) must stay case-sensitive and keep their distinguishing anchors -- `Bypass\s+Permissions\s+mode`, not a bare `bypass permissions` - because Claude Code's status line -permanently contains the string `bypass permissions`, so a case-insensitive or unanchored loosening -would match a dialog in every pane forever and break every spawn. - -`hand spawn` and `hand promote` clear the answerable ones automatically. After sending the launch -command, each polls the pane. Whether a worker is running is herdr's answer, not 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 then exited leaves the text behind but no agent, and 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 used only to spot dialogs (`internal/harness`'s `FirstRunPromptsFor`): a known one is -answered, and success needs the pane to hold a live agent and stay free of both known dialogs and -the harness's generic unrecognized-dialog fallback for the settle window. A harness's 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. - -That text is read from the pane's recent scrollback (`pane read --source recent`), not its visible -viewport, because a pane in an unattached herdr session is too short to show a whole dialog - 23 -rows against 61 in an attached one - and what it clips is the lower half, where the option and -footer lines that identify a dialog live. - -Reading scrollback rests on a measured premise: 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 `hand spawn`ed 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. A read that still matches a catalogued dialog is -therefore treated as that dialog still being up, and the launch runs out its poll window rather than -being confirmed. If that premise stops holding on a later Claude Code version, spawn fails on the -deadline instead of confirming a healthy worker. That direction is chosen, not an oversight: a wrong -deadline failure is loud and fixable, while confirming an unread dialog is issue #28 itself. -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. +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/promote with that 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 the possibility of 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. See `cmd/launch.go`'s `confirmLaunch` for the polling/timeout values and why they were -chosen. +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, not only claude. +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 @@ -1945,14 +1822,13 @@ instead of attached. The Claude and OpenCode forms above were verified against the installed CLI versions. Codex, Grok, and Pi retain unverified templates until those binaries are installable; whoever verifies them must confirm interactive (not headless) launch, not just flag names. -The harness module is the single place that constructs these commands. +`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 (Codex, 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 (see `hand spawn`). The warning is a stopgap, not -the fix: carrying the text properly needs an inline-prompt flag verified against a real `--help` for -each of the three, which is blocked on the same missing binaries as -atqamz/secondhand#36. +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 @@ -1963,46 +1839,31 @@ The herdr server must be running before any `hand` operation that touches tabs/p ### Workspace and tab model -- One workspace per project. Workspace label = `hand:`, not the bare project name - (atqamz/secondhand#118): herdr derives a label from a workspace root directory's basename, so it - is neither unique nor owned by `hand` - a project cloned to a directory sharing that basename - with something else on the machine (the fleet home itself, another tool's workspace) produces a - second workspace with the identical bare label, and `FindWorkspaceByLabel` returning whichever one - `herdr workspace list` happens to return first is a silent dispatch into a workspace `hand` never - created. The `hand:` prefix is `hand`'s own namespace, mirroring the existing `hand:` - convention for treehouse worktree ownership - it does not make the label unique on its own, but it - does mean a collision now requires another `hand`-managed project to coincidentally share the same - name, not any directory on the system. A workspace already created under the bare label from - 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). +- 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 real values: `working`, `idle`, `blocked`, -`done`, `unknown`. `working` and `blocked` mean what they say. `unknown` is herdr's -own degrade-gracefully value. `idle` and `done` are where the two-value mental model -("idle: waiting for input" / "done: work is finished") that shipped in an earlier -version of this doc turned out to be wrong, and produced the bug tracked as #30. - -`done` does not mean the task is done. It is herdr's own notification bookkeeping: -when a pane goes from working (or blocked) to not-busy, herdr reports the transition -as `idle` only if a live, OS-focused herdr client currently has that pane's tab -active at the instant of the transition (its internal `seen` flag); otherwise it -reports `done`. `hand` polls the API and never focuses a client on a worker's pane, -so it observes `done`, essentially always, for this transition - never `idle`. - -The corollary: for `hand`'s headless deployment, `done` versus `idle` carries no -task-outcome information at all, only whether a human happened to be looking. Which -means the report channel (see "Report channel") is the *only* source of task outcome, -not a supplement to herdr's status. `hand` treats `idle` and `done` identically - -both just mean "the pane stopped being busy" - and never infers completion from -either one alone. +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 | @@ -2019,13 +1880,10 @@ either one alone. # list workspaces herdr workspace list -# create workspace without focusing it - herdr cannot create an empty workspace, so this always -# creates a root tab and pane too, at --cwd; hand points --cwd at the worktree (not the clone) and -# reuses that root tab as the first task's tab (see "herdr tab rename" below) rather than -# discarding it and creating a second tab, which would leave it behind as an orphan shell. -# --env blanks any CLAUDE_CODE_CHILD_SESSION/CLAUDE_CODE_SESSION_ID/CLAUDECODE the herdr server -# itself inherited, so a server started from inside a Claude Code session never hands its own -# session identity, or a silently disabled transcript, to a pane it creates (atqamz/secondhand#109) +# 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= @@ -2100,19 +1958,14 @@ Secondhand's only no-mistakes awareness: ### Gate preflight `no-mistakes`'s own state is keyed on the absolute `working_path` of the repo it was initialized -against. Two real histories orphan that row without either `hand` or `no-mistakes` reporting it -anywhere: the fleet home gets renamed (moving every project's clone path at once), or a project is -registered with `--mode no-mistakes` but `no-mistakes init` is never run against it. Both leave a -`no-mistakes`-mode project silently ungated - a worker's `axi run` either fails deep into the task -or, worse, is never invoked at all - with nothing obliging anyone to notice. +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. `no-mistakes status` always exits 0, -whether or not the repo is initialized, and reports both of the histories above with the identical -text `repo not initialized (run 'no-mistakes init' first)` - there is no way to distinguish a -never-initialized repo from a stale renamed one from the outside, so the preflight does not try. -The outcome is read from that text, never from `~/.no-mistakes/state.sqlite` directly, -which is another tool's private schema. +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. @@ -2126,16 +1979,12 @@ Five outcomes: - **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` itself exits 0 and prints - `not in a git repository` in this case - a plain-looking success that used to read as a ready - gate. Refuse instead with `no-mistakes clone path is not a git repository: `, also a general - error (exit code `1`), never `GateReady`: a caller must never be told to dispatch into a project - the gate cannot cover. +- **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 last two are atqamz/secondhand#97, and neither reads `~/.no-mistakes/state.sqlite` either. The -non-git one is read from `no-mistakes status`'s own text, the same way every other outcome here is. -The missing clone path is caught by stat-ing that path before the binary is run at all, because -otherwise the failed chdir is what surfaces, as the misleading "binary not found or not runnable". +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 @@ -2149,44 +1998,36 @@ never-initialized gate is visible without waiting for a spawn or promote to refu ### Gate-run visibility Gate preflight answers whether a project's gate is initialized. It says nothing about whether any -given shipped PR actually went through it: a `no-mistakes`-mode project's gate can be ready and -still never have run against the branch a particular task's PR came from - the project was -registered after the fact, the PR was opened by hand outside the `pr` step, or the gate was -bypassed with `--skip-gate-check`. atqamz/secondhand#92 is that gap: `hand status` had no way to -surface a `done` `ship` task with a recorded PR that never went through a gate run at all. +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 approach `GateStatus` already uses for gate preflight, never -`~/.no-mistakes/state.sqlite` directly. 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, so a fleet with several -`done` `ship` tasks on one project pays one `no-mistakes` process rather than one per task. - -This establishes only that the no-mistakes `pr` step opened this exact PR from a run that reached -`completed` - nothing more, and the wording is deliberately no stronger than that: -- It is not a per-commit answer. `no-mistakes`'s own state is keyed on `working_path`, not per-PR - (see "Gate preflight" above), and `hand` records no head commit for a task's PR to compare - against. A push to the same branch after the matched run completed - amending the PR without a - new run - still reads as gated, exactly as it did before that push. -- A PR opened by hand outside the no-mistakes `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. +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 for the identical - failure in `hand project list`, so a question the check could not answer never renders as the - stronger claim `no run found`. That bucket covers a missing clone, an unrunnable binary, and - - read from `no-mistakes runs`'s own output text rather than its exit code, exactly as gate - preflight reads them - a gate that was never initialized or whose `working_path` went stale, and - a clone path that is not a git repository. The uninitialized case matters most: no-mistakes still - holds that repo's completed runs, so reading its refusal as an empty run list would report a - genuinely gated PR as never gated. - -A marker that claims more certainty than this data supports would be worse than no marker at all, -so `hand status` only ever says what the two outcomes above actually establish - never "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 - in progress, `scout`, no PR yet, project not registered or not -`no-mistakes` - has nothing this check can say, so it stays silent. + 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 @@ -2212,9 +2053,7 @@ Recommended structure: ``` -There is no template engine, no placeholder substitution, no generated sections. -The supervisory agent is an LLM - it writes good briefs naturally. -Removing template machinery removes a maintenance burden and failure mode. +There is no template engine, no placeholder substitution, and no generated sections. ### Declared model and effort @@ -2238,16 +2077,14 @@ scope, so a respawn or a promote picks the declaration up again instead of falli 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. A brief is -prose that happens to carry two optional settings, not a config file that happens to contain prose. -Model names are not validated against a list, which would rot the first time a model ships. +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 worker opens the brief itself, so the -launch prompt gains one sentence marking the block's `model` and `effort` keys as dispatch metadata -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. Only the prompt-bearing harnesses carry that -sentence: `codex`, `grok` and `pi` are handed the brief as a file with no prompt at all, so a -declaring brief reaches them undisclaimed. +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. Only +the prompt-bearing harnesses carry that sentence: `codex`, `grok` and `pi` are handed the brief as a +file with no prompt at all, so a declaring brief reaches them undisclaimed. A declared effort under a harness that cannot apply one warns on stderr, as does a declared model under `codex`, `grok` or `pi`, and so does the operator-decision rule and the front-matter @@ -2280,7 +2117,7 @@ Conventions: - 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. Rolling off rather than deleting keeps the file the agent reads at session start short without discarding the record; a live firstmate home carried a 13 KB backlog against a 50 KB done-archive (atqamz/secondhand#47). +- 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). @@ -2320,17 +2157,16 @@ Delivery modes: ## State management -### Design decisions +### 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 events.log and herdr event streams, not from accumulating status lines. - `state/completions.jsonl` is a deliberate exception, not a contradiction: it does not track a task's current state, which the store already owns and which teardown deletes outright. It is durable history of a state that no longer exists, kept because it is the only record of a completion once `hand teardown` removes the row and `hand status` stops showing it - the log this principle warns against is one substituting for state that should be current, not one recording state that is gone for good. -- **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. Reading a previous rendering back in as evidence is what produced atqamz/secondhand#53's accuracy defects, and no code path does it any more. -- **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): herdr's agent state answers "is the pane busy," not "why did it stop" or "what actually happened," and that gap is exactly what caused done/blocked/needs-decision to go unreported in production. This file is not a second copy of herdr's state - it's a channel for information herdr has no way to carry, and it exists only because that specific gap caused a real incident, per principle 5 (no feature without friction). - This is the strongest argument for the whole design: herdr's `idle`/`done` split (see "Agent state") carries no task-outcome information at all for `hand`'s headless deployment, only whether a human happened to be looking at the time. The report channel isn't a supplement to herdr's status for learning how a task ended - it's the only source of that information there is. +- **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 for the full reasoning: a task-scoped hold would be destroyed by `hand teardown` exactly when it matters most. +- **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 @@ -2355,94 +2191,89 @@ Fixed vocabulary (anything else is malformed, and malformed lines are surfaced, - `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, not being told - it 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 (see atqamz/secondhand#87). +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 (a write still in flight) is left unconsumed until the next tick. Because the offset is durable, a restarted `hand watch` resumes exactly where it stopped: no already-surfaced line is replayed into stdout or `state/events.log`, and no line written moments before the restart is dropped. 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, so a pane found not-busy after a restart isn't mistaken for an unexplained stop - see "What survives a `hand watch` restart". -- Blank and whitespace-only lines are skipped by every reader, so `hand status`'s history never shows an entry `hand watch` didn't surface and a stray trailing newline can't masquerade as a malformed terminal report. +- `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 also tolerates the channel being rewritten in place rather than appended to, which is what a worker reporting with a truncating `>` redirect does. Every offset the reader persists sits immediately past a newline, so an offset whose preceding byte is no longer a newline is pointing into the middle of a line the rewrite replaced, and tailing restarts from the beginning. Without that check the fragment read from mid-line classified as a `malformed report` naming a healthy worker and quoting a mid-word slice of its own well-formed report, and since the fragment's start is decided by the length of the *previous* report, nothing about the report itself predicted it (atqamz/secondhand#140). Re-announcing a report the operator has already seen is the deliberate direction here: a duplicate `working:` line costs a wake at worst, while a fabricated malformed report costs a wake *and* misrepresents the worker. -- **The offset is trusted only together with a digest of what it consumed**, persisted as `report_digest` beside it. The newline check above 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, so the new report was skipped entirely - never announced, with `last_report_state` left stale. Reports are one line of house-style prose and consecutive ones run within a few characters of each other, so the collision is a matter of time rather than a contrived input, and the cost is not a missed wake: the deferred verification is gated on the last recorded report state (`ClassifyDeferredDone`), so a same-length `done:` rewrite means a worker that finished is never announced as finished (atqamz/secondhand#149). 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, so an upgrade replays nothing; the first tick that consumes a line records the digest. Nothing outside the file is consulted: an mtime is granular enough to miss a rewrite inside its own resolution, and an inode is unchanged by precisely the in-place rewrite at issue. -- 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 - so free text appended after a real report cannot erase it or make the two commands answer differently about the same quiet pane. -- **A `done` report is never trusted alone.** A worker's belief that it's finished is a claim, not a fact; it's cross-checked against completion evidence the worker didn't produce before it's allowed to change agent state or clear a pending decision, and until then it surfaces as "reported-done", not "done" (see `classifyReportDone` in `internal/watcher/events.go`). 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 deliverable `hand promote` itself requires. 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 (`ClassifyDeferredDone`) - including when it landed while the watcher was stopped, since the announcement is tracked by the durable `done_verified` marker rather than re-derived from whatever evidence is on disk at startup (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), since a recorded PR is what `hand merge` later merges for real. 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, and its remedy is a human running `hand pr`, which records the URL the watcher could not; 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. The one exception is silent by design: losing the lock race to the `hand pr` recording that very URL is not a failure, so 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. +- 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 worker can reach `done` or `failed` with nothing attached to hear it: no session, no `hand watch`, and - if `config/notify` is unset - no notify hook either. -The event is not lost, since it will be classified and announced whenever a watcher next runs, but until then it is announced to nobody, and nobody is who is left to notice. -So `hand status` answers it directly: 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 views, `done: (unacknowledged)` in the detail view's `report` field, and `"unacknowledged": true` in either `--json` shape - omitted when false, so a consumer written before the field sees no change on the fleet it already understands. -When the flag applies, the detail view's `report` field names the classified terminal report rather than a later unclassified line, so the clause qualifies the state it describes - 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 also derive the flag from the whole report file, never from the detail view's 5-line history window, so trailing free text can never leave one view calling a completion acknowledged that the other flags. +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`), and every announcement reaches `state/events.log` and the notify hook whether or not it reached anyone's stdout - the `--until-event` baseline ticks discard stdout and still both (see "Delivering an event to a supervisory agent"). +**`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. -A dedicated `acknowledged` column would be that same fact stored twice, with a way for the two to disagree. -`report_digest` is not a second marker: it records nothing about what reached anyone, only whether the offset beside it still describes the file it was taken from, and a rewritten channel therefore reads as entirely unacknowledged - which it is, since no watcher announced a line of it. +`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, which is what keeps this from flagging history: 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. +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. -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. -The watcher leaves an unterminated trailing line unconsumed for its next tick, since the line will still be there (see "Report channel" above); a report it has not announced yet is exactly one that has reached nobody, so leaving it unflagged would let the silent completion this exists to surface back in through the newline. -A worker mid-append is therefore flagged for the moment its line is incomplete, which is the safe direction: the same reasoning as a watcher denied the task lock, where the transient answer is calling an acknowledged report unacknowledged, never the reverse. +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 (atqamz/secondhand#63) records that an id is waiting on something, so "what needs the operator" is derived from the store rather than authored by hand in `data/backlog.md` - that file stays out of scope for holds entirely; a design that finds itself parsing it has gone wrong. - -**A hold is its own row, keyed by an arbitrary id, not a foreign key into the task table.** The alternative - a column or side table hanging off a task row - was rejected: it would fit the common case, but `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. A standalone row survives that teardown, which is the motivating case the issue names - work with no task row behind it, either never dispatched or torn down mid-question. +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 second, independent reason favored the same answer at the time: before atqamz/secondhand#111, `Open` had no schema-version mechanism - it applied `schema` with `CREATE TABLE IF NOT EXISTS`, which is a correct create against a table that does not exist yet but 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 the existing `task` table would have passed every test (which build fresh databases) and silently failed to apply to any already-migrated `state/hand.db` on disk. A brand-new `hold` table sidestepped the gap entirely: every existing database was missing the whole table, so `CREATE TABLE IF NOT EXISTS` took the create branch, not the no-op one, on both a fresh database and a migrated one. See "Schema versioning" below for the mechanism atqamz/secondhand#111 added; an ordinary column addition no longer needs a workaround like this one. +**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 (atqamz/secondhand#136). +- `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, so narrowing down a reason is a re-run, not a clear-then-set. Cleared with `hand hold clear`, which deletes the row outright: no residue survives a clear for `hand status` to find later. +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`). Being a projection is what makes its rules differ from the other two in both directions: `hand hold set --kind limit` is refused with exit 2, since a hold an operator authored by hand would be a claim about a quota `hand` never observed and nothing would ever clear it; but `hand hold clear` accepts it, because refusing that would make the one hold set on the operator's behalf the one hold they cannot undo. `hand watch` and `hand promote` clear it only when the hold on that id actually is of kind `limit`, so a machine clear never silently answers an operator's own question on the same id. -The set direction is guarded the same way, and has to be: `hand watch` writes the `limit` hold only when the id holds nothing or already holds a `limit` one, because a limit hold written over an operator's would not merely hide their question - the machine clear that follows, matching its own kind now, would delete the row and the question with it. -Yielding costs the wait nothing: a held id already refuses `hand spawn`, the schedule that resumes the worker lives on the task row, and the attempt that would have refreshed the projection says so on the watcher's diagnostic stream instead. +**`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.** The same standalone row that keeps a torn-down task's question visible would otherwise reattach it to whatever new work claimed the id next, which is the replay hazard `Delete` already guards against for the report channel by removing `state/.status` (see "Report channel"). The report channel is a volatile wake log and can simply be discarded; a hold is either an operator's open question or a live worker's quota wait, so `hand spawn` refuses with exit 3 and names `hand hold clear ` rather than clearing it silently - answering the question is an acknowledgement `hand` has no business making on the operator's behalf. Clearing the hold is therefore the explicit step that says the question is settled, and it is the only escape hatch, since a `--force`-style flag would be the silent clear wearing a different name. +**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). The reasoning that keeps an operator hold alive past teardown inverts here: there is no question left open, no worker left to resume, and no watcher that will ever clear it, so left behind it would refuse `hand spawn` on that id forever. +`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 - filtering here is what would let an external write's mistake silently disappear from "what is held" - 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: this is the one place in `hand status` that does not fail open on a read, because a `holds[0]` block and "the store couldn't be read" look identical unless the second one is a fatal error instead. +**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, which the task lock must not be (see `hand send`). The project lock is what keeps the `data/projects.md` projection whole: rendering it is a read-modify-write over the file, so a second writer rendering from its own snapshot mid-write would drop a registered project from it. +- 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"). -- File locking: machine state is written through sqlite, which serializes writers itself. - 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 (rebase or conflict resolution), not at spawn time. The agent should avoid spawning tasks that touch the same files when possible, but this is a judgment call, not an enforced constraint. -- **No session lock.** Multiple supervisory sessions can run `hand` commands. The agent is responsible for not conflicting with itself. sqlite's own locking prevents corruption; duplicate work is an agent-level problem, not a CLI-level problem. +- **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. +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. -The path is the wrong key because treehouse recycles it. -A pool slot returned to its pool keeps its directory and is handed straight back out to the next task under a brand-new identity: treehouse regenerates `lease_id` on every acquisition, including a same-holder reacquisition of the same slot, so the identity is the only part of a lease that is never reused. +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. -Keying on the path produced a false positive, not a missed collision. -`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 (see `hand teardown`). -If that removal does fail, 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. -The guard cannot miss a real one: `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. -It is defense-in-depth against `hand`'s own bookkeeping going stale, and the older claim that it prevented the stale-lease-after-crash bug (firstmate #947) was wrong: that bug was pid-based ownership, which `hand` has never used. +Every task row is compared, done and failed ones included, because a task keeps its lease until teardown returns it. -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; nothing has to be rewritten in place. +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. -Every task row is compared, done and failed ones included. -Status says nothing about whether a worktree is still held - a task keeps its lease until teardown returns it - so a status filter here would drop rows that genuinely still hold the slot. +Why: `docs/adr/the-collision-guard-keys-on-the-lease-id.md`. ### Recovery @@ -2454,33 +2285,38 @@ On restart (new supervisory agent session): 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`. -That is why the report channel is a plain append-only text file and why there is no `hand dump` (see "Which to believe when they disagree"). +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, because running it twice is what actually happens.** 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/` by an operator reading it - 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, because it spans files sqlite cannot see. Without it two `hand` processes opening a not-yet-migrated home interleave: one parses a file, the other imports it, archives it and then deletes the row under `hand teardown`, and the first lands its insert afterwards and resurrects a torn-down task. It is a lock of its own, not the project registry's, since `hand project add` and `remove` already hold that one when they trigger the registry import. +- **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, and now has one.** `Open` runs the whole `schema` string, `CREATE TABLE IF NOT EXISTS` and all, on every open - not only the first. That statement is correct for a table an existing database is missing outright, which is why adding the `hold` table was sufficient on its own for an existing `state/hand.db` to gain it on its next open. It was a silent no-op for a column an existing table was missing, since sqlite had already satisfied "if not exists" at the table level and never looked at the column list again; see "Schema versioning" below for the mechanism atqamz/secondhand#111 added to close that gap. +- **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 for exactly this: no extra table, free to read, and part of the database file itself rather than a `meta` row a stray write could get out of sync with the tables it describes. +`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 one every existing `state/hand.db` already carries, since sqlite defaults an unset `user_version` to 0 and the one real fleet home predates this mechanism entirely. 0 means "the baseline schema this commit ships", not "unknown, refuse to proceed"; the latter reading would stop the one home that exists from opening the moment this merged. -- `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 - atqamz/secondhand#48's `lease_id`, or atqamz/secondhand#78's `project.upstream` and `task.delivered_at`/`delivered_reason` - 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. atqamz/secondhand#136's `task.usage_limit_retry_at`/`usage_limit_attempts` are that ordinary two-edit case and deliberately take no backfill: 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 column whose empty default would be wrong for a row that already exists - atqamz/secondhand#128's `task.pane_started_at` - 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. -- **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, which every later open would answer by replaying those migrations against columns that are already there. Without that check, keeping `schema` and `migrations` in step would break every fresh `hand init` with "duplicate column name" while the already-migrated homes kept working: the tests-pass, production-fails asymmetry inverted, which is the exact failure mode atqamz/secondhand#111 exists to remove. The alternative - freezing `schema` at the baseline forever and reading every column addition out of `migrations` - would leave the constant lying about the current layout, and nothing but prose to stop the next reader from adding a column to it. -- **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 - an old `hand` opening a new database and writing malformed rows into it would be worse than refusing to run. -- **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`. Two `hand` processes opening the same freshly-upgraded home both re-check the version after acquiring the lock, so whichever loses the race finds the version already caught up and applies nothing, rather than re-running `ALTER TABLE ADD COLUMN` against a column the winner already added. +- **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 @@ -2498,7 +2334,7 @@ An existing fleet home has live state on disk, and the import has to meet it wit - `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, because a silent fallback is how an operator dispatches into the wrong fleet. + 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"). @@ -2521,26 +2357,26 @@ help[1]: 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, where the AXI principles put it on stdout, because `hand watch` owns stdout as an event stream a supervising agent consumes line by line. -Keeping failures off that stream means a reader never has to tell an event from an error. +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. -Hand-writing a fake per test is what let the same vacuous shape through repeatedly, so a new test extends the shared fake rather than writing its own. +A new test extends the shared fake rather than writing its own. -The rule that governs it: **a fake that answers a state-changing command identically before and after that command cannot test anything about the state change.** -So every fake models the state its own commands leave behind. +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, so the record and the check cannot drift apart silently. +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. -They are opt-in because CI installs no real `herdr` or `treehouse`; they exist so a fake that has quietly gone stale against a newer tool is discoverable by running them, rather than by a defect reaching an operator. +CI never runs them, since it installs no real `herdr` or `treehouse`. ### Unit tests @@ -2572,34 +2408,7 @@ CI therefore installs no real herdr or treehouse. - 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. -### No test categories from firstmate - -- No "isolation proof" tests (34k lines of shell needing proof of isolation is the problem). -- No "portable shards" (89 scripts needing distributed test execution is the problem). -- No "fleet snapshot schema" tests (JSON schema is Go struct tags). - -## What is NOT in scope - -These are explicit non-goals. Each lists the firstmate feature it replaces and why it's cut. - -| Cut feature | Firstmate equivalent | Why cut | -|---|---|---| -| Secondmates / federation | `fm-home-seed.sh`, `fm-pending-reply-lib.sh`, `fm-config-inherit-lib.sh`, `fm-backlog-handoff.sh` (3,500 lines) | Solves a scaling problem at 10+ projects. Start with one home. | -| X-mode / Twitter | `fm-x-*.sh`, `fmx-respond` skill (3,250 lines) | Separate product concern. Build as a separate tool if ever needed. | -| AFK daemon | `fm-supervise-daemon.sh`, `fm-afk-launch.sh` (2,150 lines) | `hand watch --until-event` as the agent's own background task covers the awake path; `hand watch`'s in-process notify hook (see "Notifying a supervisory agent with no session watching") covers the AFK half through `config/notify`. | -| Multiple backends | `backends/herdr.sh`, `backends/cmux.sh`, `backends/zellij.sh`, `backends/orca.sh`, `fm-backend.sh` (5,500 lines) | herdr only. Add tmux fallback later if herdr proves insufficient. | -| Dispatch profiles | `fm-dispatch-select.sh`, `config/crew-dispatch.json` (340 lines + skill) | Pass `--harness`/`--model`/`--effort` explicitly, declare `model`/`effort` in the brief, or set defaults in `config/`. | -| Decision holds | `fm-decision-hold.sh`, decision-hold-lifecycle skill (500 lines) | No longer cut: `hand hold set`/`hand hold clear` record a hold in the store and `hand status` renders it (see "Holds" under "State management"). Only the lifecycle skill wrapped around it stays out. | -| Hook-based guards | `fm-continuity-pretool-check.sh`, `fm-continuity-command-policy.mjs`, `fm-subagent-pretool-check.sh` (450 lines) | CLI refuses bad operations internally. No hooks. | -| PR-check migration | `fm-pr-check-migrate.sh` (1,148 lines) | No legacy to migrate. | -| Fleet snapshot / bearings | `fm-fleet-snapshot.sh`, `fm-bearings-snapshot.sh` (1,860 lines) | `hand status --json` covers it. | -| *-axi wrappers | `gh-axi`, `tasks-axi`, `lavish-axi` | Agent uses `gh` directly. Backlog is a file. No wrappers. | -| Vocabulary translation | AGENTS.md section 9 (~40 lines of translation rules) | No internal jargon to translate if AGENTS.md is 25 lines. | -| Wake queues | `fm-wake-lib.sh`, `state/.wake-queue` (596 lines) | Watcher prints to stdout + `state/events.log`. No durable queue. | -| Turn-end guards | `fm-turnend-guard.sh`, `docs/turnend-guard.md` | The supervisory agent's harness handles its own session lifecycle. | -| Persona / role-play | "captain", "ahoy", nautical theming | Pure functionality. Users add personality if they want. Operator context is not persona and is not cut by this row: `data/operator.md` carries identity, authority and hard constraints, which is configuration the agent must not have to guess at (see "Directory layout"). | -| Session lock | `fm-lock.sh`, `fm-lock-lib.sh` | No session lock. Atomic file writes prevent corruption. Agent avoids duplicate work. | -| Bootstrap / session-start | `fm-session-start.sh`, `fm-bootstrap.sh` (56K lines combined) | Agent runs `hand status`. No 187-line digest. The `SessionStart` hook `hand init` installs is not this row's bootstrap: it runs the bare command when the session opens rather than rendering a file the agent then reads (see "Ambient context"). | +Why: `docs/adr/one-stateful-fake-per-external-tool.md`. ## Distribution @@ -2640,7 +2449,7 @@ hand init ~/fleet --setup ``` `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 (see "AGENTS.md (target)"). +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. ### Self-update: `hand update` @@ -2657,12 +2466,11 @@ 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 the latest version, preserving user edits (see "AGENTS.md (target)"), and seed whichever `data/` skeleton files that home is missing, on the same absent-only terms as `hand init` (see "Directory layout"): the refreshed template directs the agent at those files, so the command that installs it leaves them in place rather than pointing at nothing. Outside any fleet home both are skipped silently; a `HAND_HOME` that names no fleet home is a warning, not a silent skip, since that is a misconfiguration rather than an absence. Seeding creates the runtime directories first, exactly as `hand init` does: a home resolves as one on its `state/hand.db` marker alone, so the `data/` directory it seeds into is not guaranteed to be there. 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, so two runs against the same broken home say the same thing. +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, so a caller reads one schema rather than a -set of lines that appear or do not: +Every run emits the same seven fields whatever happened: ``` hand update @@ -2678,14 +2486,12 @@ 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: they name a fault in the world -around a successful update, not a field of its result. +`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, since there is nothing to do: +`--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 @@ -2700,8 +2506,6 @@ help[1]: - Run `hand update` to install v0.4.0, which also refreshes this home's AGENTS.md template ``` -Same pattern as `no-mistakes update`, `treehouse update`, and `herdr update`. - **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. ``` @@ -2711,7 +2515,7 @@ Run "hand update" to update ### Release pipeline -Automated via [release-please](https://github.com/googleapis/release-please) (same as no-mistakes and treehouse). +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.). @@ -2738,9 +2542,7 @@ Automated via [release-please](https://github.com/googleapis/release-please) (sa **`.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. -Same CI pattern as no-mistakes and treehouse: format, vet, lint, test across OS matrix, e2e against faked herdr and treehouse (no real ones installed, see "Integration tests"), plus a nix-build job guarding the flake package, then release-please for automated releases. - -**`.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 (atqamz/secondhand#116). +**`.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 @@ -2770,7 +2572,7 @@ Files tracked in the source repo (not generated by `hand init`): **`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]`, without which the `//go:build e2e` package in `tests/e2e` is invisible to the linter. +**`.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. @@ -2779,53 +2581,8 @@ CONTRIBUTING.md's "Comments" section owns the two rules it checks, their exempti **`flake.nix`:** the tracked file is authoritative - a `packages.default` derivation building the `hand` binary and a `devShells.default` carrying the Go toolchain. -**`CONTRIBUTING.md`:** the tracked file is authoritative. - **License:** MIT. No CD beyond the release - `hand update` is the distribution channel, not a deploy pipeline. -## Implementation plan - -### Phase 1: Core lifecycle - -Get spawn-work-teardown working end-to-end. - -1. `hand init` (with `--setup`) - create runtime directories, interactive harness discovery. -2. `hand project add` - clone and register. -3. `hand spawn` - treehouse worktree + collision guard + herdr tab + launch agent. -4. `hand status` - read state + herdr agent state. -5. `hand send` - send message to herdr pane. -6. `hand teardown` - verify landed, close tab, return worktree. - -Deliverable: can spawn a worker, watch it work, and clean up after. - -### Phase 2: Supervision and lifecycle - -Make the watcher work so the supervisory agent doesn't have to poll manually. - -7. `hand watch` - event loop with herdr push/poll + event log. -8. `hand merge` - merge PR (with CI check) or local fast-forward. -9. `hand project sync` - fast-forward clones. -10. `hand promote` - scout to ship promotion. -11. `hand notify` - out-of-band notifications. - -Deliverable: fully supervised fleet lifecycle. - -### Phase 3: Polish - -12. `hand status --json` - machine-readable fleet state. -13. Error messages and edge cases. -14. Integration tests. -15. README and AGENTS.md finalization. - -Deliverable: ready for daily use. - -## AGENTS.md (target) - -**`internal/agentsmd/agentsmd.go`'s `generatedBody` constant** is authoritative - the template `hand init` writes into a new fleet home's `AGENTS.md` and `hand update` refreshes there, delimited by `hand:generated` markers so anything a user adds outside that span survives a refresh. `hand doctor` (see the CLI specification) reports perishable content, generated-block drift, and absent generated markers in a real fleet home's `AGENTS.md` without fixing any of them - the first two fail the command, absent markers alone does not (`agentsmd.SeverityInfo`). - -One rule in that template, `agentsmd.OperatorDecisionRule`, is also an exported constant, because a worker runs in a worktree that is never under the fleet home and so never loads the home's `AGENTS.md`. `internal/harness` appends that same constant to every prompt-carrying launch template (see "Harness launch templates"), the only channel the rule has to a worker. It stays one string in one place rather than two copies that drift. - -This repo's own `AGENTS.md` carries no `hand:generated` markers, so `agentsmd.Refresh` declines to touch it even if a maintainer runs `hand init` in the checkout and `state/hand.db` makes `internal/home.IsHome` report true for it. Rather than hand-keep a second copy of `generatedBody` in sync (the drift #44 was filed against), this file's own Rules section points at `generatedBody` and SPECS.md by name instead of restating it: one prose template, one place it can go stale. -`hand doctor` run in that checkout therefore reports the absent markers every time - correct, since the template really will never refresh here - but that finding is informational (see "`hand doctor`"), so the command still exits `0` in this checkout rather than failing forever over a state the maintainers chose on purpose. +Why: `docs/adr/the-comment-rule-is-two-mechanical-checks.md`. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..b0650e3 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,55 @@ +# 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. + +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. + +## When to write one + +The bar is that a future worker might undo the decision by accident. + +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. + +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. + +## Format + +Each record is one file, `docs/adr/.md`, with this shape: + +```markdown +# + +- 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. 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 new file mode 100644 index 0000000..52b3650 --- /dev/null +++ b/docs/adr/a-fork-projects-upstream-is-declared-never-inferred.md @@ -0,0 +1,48 @@ +# 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 new file mode 100644 index 0000000..84f9c3f --- /dev/null +++ b/docs/adr/a-silent-report-channel-is-parked-on-three-bounds.md @@ -0,0 +1,70 @@ +# 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 new file mode 100644 index 0000000..49691fe --- /dev/null +++ b/docs/adr/a-steer-that-never-lands-leaves-a-durable-trace.md @@ -0,0 +1,73 @@ +# 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 new file mode 100644 index 0000000..1d7468d --- /dev/null +++ b/docs/adr/ambient-context-is-a-session-hook-not-a-file.md @@ -0,0 +1,63 @@ +# Ambient fleet context is a `SessionStart` hook, not a rendered file + +- Date: 2026-08-04 +- Status: accepted +- Issues: atqamz/secondhand#62, atqamz/secondhand#64 +- PRs: atqamz/secondhand#122 + +## 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`. + +## 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. + +`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. + +## 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. + +## 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. 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 new file mode 100644 index 0000000..9352cdd --- /dev/null +++ b/docs/adr/an-unrecorded-pr-is-recovered-by-head-ref.md @@ -0,0 +1,70 @@ +# 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 new file mode 100644 index 0000000..61cdb86 --- /dev/null +++ b/docs/adr/believe-the-status-file-and-ship-no-hand-dump.md @@ -0,0 +1,52 @@ +# 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 new file mode 100644 index 0000000..ae6e3df --- /dev/null +++ b/docs/adr/deliver-names-the-state-instead-of-widening-force.md @@ -0,0 +1,61 @@ +# 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 new file mode 100644 index 0000000..3dddfb5 --- /dev/null +++ b/docs/adr/gate-checks-read-no-mistakes-output-not-its-database.md @@ -0,0 +1,75 @@ +# Gate checks read `no-mistakes`'s own output, never its database + +- Date: 2026-08-04 +- Status: accepted +- Issues: atqamz/secondhand#92, atqamz/secondhand#97 +- PRs: none single + +## 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`. + +## 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. + +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. + +## 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. + +## 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. diff --git a/docs/adr/harness-templates-launch-interactively.md b/docs/adr/harness-templates-launch-interactively.md new file mode 100644 index 0000000..b582b64 --- /dev/null +++ b/docs/adr/harness-templates-launch-interactively.md @@ -0,0 +1,59 @@ +# Every harness launch template runs the harness interactively, never headless + +- Date: 2026-08-04 +- Status: accepted +- Issues: 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. + +## 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. + +A declared model or effort a harness has no flag for is warned about on stderr rather than dropped in silence (atqamz/secondhand#152). + +## 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. + +## 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. diff --git a/docs/adr/herdr-workspace-labels-carry-a-hand-prefix.md b/docs/adr/herdr-workspace-labels-carry-a-hand-prefix.md new file mode 100644 index 0000000..5d0d5d6 --- /dev/null +++ b/docs/adr/herdr-workspace-labels-carry-a-hand-prefix.md @@ -0,0 +1,56 @@ +# 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 new file mode 100644 index 0000000..936ee6c --- /dev/null +++ b/docs/adr/holds-are-their-own-table.md @@ -0,0 +1,77 @@ +# A hold is its own row keyed by an arbitrary id, not a column on the task + +- Date: 2026-08-04 +- Status: accepted +- Issues: atqamz/secondhand#63, atqamz/secondhand#111, atqamz/secondhand#136 +- PRs: none single + +## 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. + +## 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. + +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. + +## 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. + +## 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`. diff --git a/docs/adr/launch-confirmation-trusts-herdr-not-the-screen.md b/docs/adr/launch-confirmation-trusts-herdr-not-the-screen.md new file mode 100644 index 0000000..2b6ccee --- /dev/null +++ b/docs/adr/launch-confirmation-trusts-herdr-not-the-screen.md @@ -0,0 +1,77 @@ +# 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 new file mode 100644 index 0000000..0a73ab8 --- /dev/null +++ b/docs/adr/notify-is-a-filtered-consumer-of-the-event-stream.md @@ -0,0 +1,85 @@ +# 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 new file mode 100644 index 0000000..57f6965 --- /dev/null +++ b/docs/adr/one-stateful-fake-per-external-tool.md @@ -0,0 +1,67 @@ +# One shared stateful fake per external tool, checked against a recorded transcript + +- Date: 2026-08-04 +- Status: accepted +- Issues: atqamz/secondhand#40 +- PRs: atqamz/secondhand#158 + +## 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. + +## 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. + +## 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. + +## 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. 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 new file mode 100644 index 0000000..f772640 --- /dev/null +++ b/docs/adr/one-watcher-per-fleet-home-guarded-by-an-flock.md @@ -0,0 +1,56 @@ +# One watcher per fleet home, and ownership is an flock rather than the pid + +- Date: 2026-08-04 +- Status: accepted +- Issues: none +- PRs: atqamz/secondhand#138 + +## 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. + +## 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. + +`--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. + +## 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`. + +## 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. 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 new file mode 100644 index 0000000..98cc990 --- /dev/null +++ b/docs/adr/output-is-toon-by-default-and-json-is-retained.md @@ -0,0 +1,57 @@ +# Output is TOON by default and `--json` is retained unchanged + +- Date: 2026-08-04 +- Status: accepted +- Issues: atqamz/secondhand#45, atqamz/secondhand#100 +- PRs: atqamz/secondhand#155 + +## 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. + +## Decision + +TOON is the default output of every command, rendered through the single `internal/axi` renderer rather than per command. + +`--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`. + +## 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. + +## 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. diff --git a/docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md b/docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md new file mode 100644 index 0000000..34c1d97 --- /dev/null +++ b/docs/adr/secondhand-rebuilds-firstmate-as-one-go-binary.md @@ -0,0 +1,86 @@ +# Secondhand keeps firstmate's concept and rebuilds its execution as one Go binary + +- Date: 2026-08-04 +- Status: accepted +- Issues: none single +- PRs: none single + +## 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. + +## 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: + +- 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. + +## 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`. + +## 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. 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 new file mode 100644 index 0000000..1c6d583 --- /dev/null +++ b/docs/adr/the-collision-guard-keys-on-the-lease-id.md @@ -0,0 +1,59 @@ +# 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 new file mode 100644 index 0000000..7c0707b --- /dev/null +++ b/docs/adr/the-comment-rule-is-two-mechanical-checks.md @@ -0,0 +1,90 @@ +# 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 new file mode 100644 index 0000000..8a6ff89 --- /dev/null +++ b/docs/adr/the-completion-store-is-an-uncapped-append-only-sibling.md @@ -0,0 +1,78 @@ +# The completion store is an uncapped append-only log, not a share of `events.log` + +- Date: 2026-08-04 +- Status: accepted +- Issues: atqamz/secondhand#78 +- PRs: none single + +## 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. + +## 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. + +`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. + +## 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. + +## 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. 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 new file mode 100644 index 0000000..4fb8889 --- /dev/null +++ b/docs/adr/the-landed-work-guard-reads-the-work-not-the-record.md @@ -0,0 +1,89 @@ +# The landed-work guard reads the work, not the record, and every unresolved question fails closed + +- Date: 2026-08-04 +- Status: accepted +- Issues: atqamz/secondhand#79, atqamz/secondhand#129 +- PRs: none single + +## 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. + +## 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. + +**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. + +## 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. + +## 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`. 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 new file mode 100644 index 0000000..a7934c1 --- /dev/null +++ b/docs/adr/the-report-channel-is-the-only-outcome-signal.md @@ -0,0 +1,66 @@ +# The report channel is the only source of a task's outcome + +- Date: 2026-08-04 +- Status: accepted +- Issues: atqamz/secondhand#53, atqamz/secondhand#87 +- PRs: none single + +## 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. + +## 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`. + +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). + +## 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. + +## 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`. 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 new file mode 100644 index 0000000..900d290 --- /dev/null +++ b/docs/adr/the-report-offset-is-trusted-only-with-a-digest.md @@ -0,0 +1,71 @@ +# 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 new file mode 100644 index 0000000..278a882 --- /dev/null +++ b/docs/adr/the-schema-version-lives-in-pragma-user-version.md @@ -0,0 +1,74 @@ +# The schema version lives in `PRAGMA user_version`, and a fresh database never replays migrations + +- Date: 2026-08-04 +- Status: accepted +- Issues: atqamz/secondhand#111, atqamz/secondhand#48, atqamz/secondhand#78, atqamz/secondhand#128, atqamz/secondhand#136 +- PRs: none single + +## 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. + +## 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. + +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. + +## 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. + +## 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`. diff --git a/docs/adr/the-until-event-exit-is-the-delivery.md b/docs/adr/the-until-event-exit-is-the-delivery.md new file mode 100644 index 0000000..8eea248 --- /dev/null +++ b/docs/adr/the-until-event-exit-is-the-delivery.md @@ -0,0 +1,80 @@ +# The process exit is how an event is delivered to a supervisory agent + +- Date: 2026-08-04 +- Status: accepted +- Issues: none +- PRs: atqamz/secondhand#125 + +## 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. + +## 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. + +One invocation delivers one wake. +Re-arming is the caller's own next step after acting on the exit. + +## 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. + +## 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`. diff --git a/docs/adr/the-watcher-persists-what-it-announces.md b/docs/adr/the-watcher-persists-what-it-announces.md new file mode 100644 index 0000000..f3d5322 --- /dev/null +++ b/docs/adr/the-watcher-persists-what-it-announces.md @@ -0,0 +1,69 @@ +# 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 new file mode 100644 index 0000000..556db0e --- /dev/null +++ b/docs/adr/usage-limit-detection-is-a-harness-capability.md @@ -0,0 +1,67 @@ +# Usage-limit detection is a harness capability, not a condition in the poll loop + +- Date: 2026-08-04 +- Status: accepted +- Issues: atqamz/secondhand#136, atqamz/secondhand#81, atqamz/secondhand#84, atqamz/secondhand#85, atqamz/secondhand#128 +- PRs: atqamz/secondhand#154 + +## 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. + +## 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 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. + +## 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. + +## 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. diff --git a/internal/agentsmd/agentsmd.go b/internal/agentsmd/agentsmd.go index 3253c20..aaeb6a7 100644 --- a/internal/agentsmd/agentsmd.go +++ b/internal/agentsmd/agentsmd.go @@ -1,7 +1,7 @@ // 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 (hand doctor), both -// described in SPECS.md's "AGENTS.md (target)" section. +// one for perishable content and generated-block drift, the checks SPECS.md +// specifies under "hand doctor". generatedBody is the template itself. package agentsmd import ( @@ -183,7 +183,7 @@ type Violation struct { // 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 "AGENTS.md (target)"). A nil result with no error is an absence - no fleet home, or no file. +// (SPECS.md's "hand doctor"). A nil result with no error is an absence - no fleet home, or no file. func Check(dir string) ([]Violation, error) { isHome, err := home.IsHome(dir) if err != nil { @@ -248,7 +248,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 \"AGENTS.md (target)\" 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 (see SPECS.md's \"hand doctor\" section)", Severity: SeverityInfo, }) case content[blockStart:blockEnd] != strings.TrimSuffix(generatedBlock(), "\n"): diff --git a/internal/faketool/FIDELITY.md b/internal/faketool/FIDELITY.md index 8c61cbf..355cec4 100644 --- a/internal/faketool/FIDELITY.md +++ b/internal/faketool/FIDELITY.md @@ -175,6 +175,15 @@ Exit 0, with the new tab and its root pane. Exit 0, and the new label is what `tab list` reports from then on. `tab_not_found` with exit 1 for a tab that has been closed. +### `herdr pane get ` + +Exit 0 with the pane, carrying `agent` (the detected harness name, empty when no harness runs in it) and `agent_status`, one of `working`, `idle`, `blocked`, `done`, `unknown`. +`pane_not_found` with exit 1 for a pane whose tab has been closed. + +`idle` and `done` are one transition, not two states: when a pane goes from `working` or `blocked` to not-busy, herdr reports `idle` only if a live OS-focused client had that pane's tab active at the instant of the transition (its internal `seen` flag), and `done` otherwise. +`hand` polls the API and never focuses a client on a worker's pane, so it observes `done` essentially always for this transition and `idle` essentially never. +Neither value carries task-outcome information for a headless fleet; see `docs/adr/the-report-channel-is-the-only-outcome-signal.md`. + ### `herdr pane read --source recent --lines ` Exit 0 with bare text, and **empty for a pane whose own shell has not painted yet** - a read taken immediately after `workspace create` returns nothing at all. diff --git a/internal/project/project.go b/internal/project/project.go index 2054062..c441811 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 one to believe" covers a disagreement. +// projection. SPECS.md's "Which to believe when they disagree" covers a conflict. package project import (