Gitmoot CLI Reference
Use these commands from an agent session only when the user asks for Gitmoot setup, status, agent coordination, or PR-comment workflow help.
Install And Update
curl -fsSL https://gitmoot.io/install.sh | sh
gitmoot version
gitmoot update --check
gitmoot update --restart-daemon
gitmoot doctor [--home <path>] [--repo <path>] [--json]
Verify GitHub access before PR workflows:
gh auth status
gitmoot doctor is the environment preflight: it validates gh auth (with an
actionable remediation hint) and live-probes the Claude credential selected by
runtime-auth.env, so a bad credential is caught before jobs stall on it. Run
it after install and before starting the daemon. It also reports delegation
worktree count and logical disk size, warning at 10 stale worktrees or 1 GB and
distinguishing aged-final reclaimable owners from pinned non-final owners. A
non-zero quarantined cleanup-obligation count is also a worktree warning;
inspect those rows with gitmoot job cleanup list --state quarantined. A running
job whose directly recorded runtime PID is confirmably dead is a
required stuck jobs failure; legacy jobs with no recorded PID and hosts where
process identity cannot be verified are neutral and produce no ghost-job
finding.
A runtime that is installed and contract-valid is not necessarily SERVING, and
doctor reports the difference (#1558). When the engine is currently holding a
job off a provider refusal - a runtime_quota or runtime_auth blocker whose
recorded retry time is still in the future - the runtime serving check warns
and names the runtime, the blocker class, the retry time and the job id. The
verdict is the engine's own forward-looking hold, not a second opinion about the
provider: a hold whose retry time has passed, a job that reached a terminal
state, a blocker row carrying no retry time, and a job whose runtime cannot be
attributed are all neutral, so a stale row can never be read as a current
refusal. It is a warning rather than a failure because the binary and its
contract are fine; what the operator needs is to not put that runtime on a
review panel today.
Doctor validates [remote_exec] through the same configuration loader used by
job dispatch. An absent section passes because remote execution is opt-in; an
invalid backend, identity pair, numeric identity, or local root is a required
failure carrying the loader's exact error. Use --home /x to preflight
/x/.gitmoot/config.toml without reading the default home.
For organization roles with an effective recycle_after, doctor also reads the
durable org_role_presence.last_seen_at signal. It warns when a specific role
has never been observed or has been inactive past that bound; an unreadable,
malformed, or future timestamp is reported as unverified, never healthy.
This deterministic absence check does not enqueue an agent job and is separate
from gitmoot agent heartbeat, whose configured schedules do enqueue jobs.
Doctor also reports the live permission-policy observation count, its recorded
home-store baseline, and the delta. This is instrumentation, not protection:
not-applied means Gitmoot supplied no permission-policy flag, not that the job
was unsandboxed. Arm the off-by-default daemon ratchet with
[daemon].permission_policy_observation_enabled = true; its first live tick
records the baseline, any newly introduced configuration emits a structured
event even if the total is unchanged or lower, and only a strict subset lowers
the baseline. The ratchet contains only fixable live agent configurations.
Historical jobs whose agent identity no longer resolves are excluded and appear
on a separate healthy doctor line; observation events carry the same residue as
unresolved_job_agents.
It also reports the SQLite auto-vacuum mode. New homes use bounded incremental
reclaim automatically. A legacy home remains a non-blocking warning until an
operator deliberately converts it during an idle maintenance window:
sqlite3 "$HOME/.gitmoot/gitmoot.db" \
'PRAGMA auto_vacuum=INCREMENTAL; VACUUM;'
The one-time command fully rewrites the database; the daemon never runs a full
VACUUM automatically. sqlite3 is an optional operator tool, not a Gitmoot
runtime dependency. For a non-default home, use the exact path printed by
gitmoot doctor.
One-shot onboarding: gitmoot setup registers the repo and an agent in one
command (--repo owner/repo --agent <name> --runtime codex|claude|shell --session <ref> [--role <role>] [--path .] [--start-daemon]). --repo,
--agent, --runtime, and --session are all required — setup errors out
if any is missing; --session takes a runtime session reference, last, or a
shell command.
--watch-issues is on by default in setup, so the daemon comes up
tagging-ready for @<agent> issue mentions.
Home And Config
Local state lives in the Gitmoot home (default ~/.gitmoot): the SQLite store,
logs/, workspaces/, evals/, artifact_blobs/, and config.toml.
gitmoot init # create the Gitmoot home
gitmoot config path # print the config file location
gitmoot config show # print the effective config
Set the GITMOOT_HOME environment variable (or pass the global --home <path>
flag, accepted by nearly every command) to relocate everything — useful for
isolated test homes.
Runtime Plugins
Install Gitmoot's Agent Skill into Codex or Claude Code when the user wants the runtime to discover Gitmoot workflow guidance from its plugin system:
gitmoot plugin install codex
gitmoot plugin install claude
gitmoot plugin doctor
Inspect or build packages without installing:
gitmoot plugin build codex
gitmoot plugin build claude
gitmoot plugin path codex
gitmoot plugin path claude
gitmoot plugin doctor codex
gitmoot plugin doctor claude
gitmoot plugin codex-launch --repo .
gitmoot plugin codex-launch --config-snippet
Claude scopes are supported with --scope user|project|local. Codex ignores
--scope because the current Codex plugin install command does not use it.
Use plugin codex-launch when Codex needs sandbox access to the resolved
Gitmoot home on Linux, macOS, or Windows. It prints a codex-face --cd ... --add-dir ... -s workspace-write launch command, or a persistent config
snippet with --config-snippet.
Runtime Metadata Registry
Gitmoot drives five built-in runtimes (codex, claude, kimi, omp, plus the
subscribe-only shell). Each carries
declarative metadata — advertised capabilities, default model and effort values,
an advisory list of known-valid models, and a descriptor of where token usage is read from. Inspect
the resolved registry:
gitmoot runtime list
gitmoot runtime list --json
The values come from the compiled built-in defaults, overlaid with any
[runtimes.<name>] overrides in config.toml. Override a built-in runtime's
recorded metadata without recompiling — for example to retarget its default
model/effort or record its known models:
[runtimes.codex]
default_model = "gpt-5.5-codex"
default_effort = "high"
models = ["gpt-5.5-codex", "gpt-5.4-codex"]
capabilities = ["review", "implement", "ask"]
usage_source = "codex exec --json turn.completed usage"
Two fields are behavioral. default_model is the model fallback when neither
the agent nor the job pins --model: agent/job --model, then default_model,
then the runtime CLI's own default. default_effort follows the same precedence
after job/agent --effort; for Codex, Gitmoot emits
-c model_reasoning_effort=<value>, and for omp it becomes --thinking <level>
whenever the resolved value is one of omp's accepted levels
(off|minimal|low|medium|high|xhigh|max|auto) — anything else is dropped, so a
typo falls back to omp's own default rather than silently downgrading the seat.
Claude and Kimi do not expose a reasoning
effort argument, so the resolved value is a no-op for those adapters. Every other
field is inspection-only, surfaced by gitmoot runtime list but changing
nothing at runtime: models is advisory (Gitmoot never rejects a --model
based on it), and capabilities gates nothing at dispatch. Adapter behavior
(auth, sandbox policy, session resume, stream parsing) always stays in Go. With
no [runtimes.*] section, and with both defaults unset, no model or effort is
forced.
A [runtimes.<name>] section can only tweak a built-in runtime's metadata; it
cannot add a new first-class runtime (that requires a code change). An unknown
runtime name is a config error surfaced by gitmoot runtime list.
Before an engine job launches, Gitmoot lazily checks the compiled runtime
contract against the installed CLI. Required argv flags come from bounded
<binary> --help probes; environmental restrictions are declared beside the
argv that triggers them. Results are supported, unsupported, or unknown.
Only a positive unsupported result blocks on the contract itself. Missing
binaries, timeouts, and unparseable help stay unknown and produce a
runtime_contract_unknown event. A missing binary is additionally refused
before any job row or worktree is created, but only when the dispatcher
explicitly declares that this dispatch builds a real adapter which will exec
that declared CLI and the execution backend runs on the local host; injected or
fake adapters, remote or attached backends, and present binaries all stay
dispatchable, and the classification stays unknown for reporting either way.
Parsed help results are cached by resolved path, size, and mtime;
unknown results use a 60-second TTL before probing again, while an updated binary
immediately invalidates either cached result. gitmoot doctor --json reports
the tri-state value in state alongside every built-in runtime's status,
installed version, answering instrument, and exact missing flag or precondition.
Doctor probes its foreground PATH, which can differ from the daemon's
EnvironmentFile PATH; compare the reported resolved_path with the daemon's
executable resolution before treating the foreground verdict as the daemon's.
A read-only seat has a second, separate capability precondition, because the
contract preflight probes the dispatching host's PATH while a seat executes a
daemon-staged copy of its runtime. When the daemon cannot stage a runtime it
publishes that name as an engine-owned command that exits 126, so an inherited
PATH cannot route the seat back to an ungranted operator installation. If the
runtime published that way is the seat's own, seat setup ends the job blocked
and records a seat_runtime_unavailable event naming the agent, the runtime and
the staging cause, before any adapter is composed, any agent instance is marked
running, or any model token is spent. A sibling runtime published unavailable is
a fact about the host, stays a daemon log line, and refuses nothing.
Transcript Retention
Runtime transcript retention is default-on. Every engine delivery appends its
stdout and stderr to a private per-job log; set enabled = false only for an
explicit opt-out. Missing or invalid configuration uses the safe defaults:
[transcripts]
enabled = true
retain = "168h"
max_total_bytes = 2147483648
Enabled capture appends every engine-delivered job attempt (foreground, daemon,
temporary session, ephemeral, and delegated jobs) to a private canonical log
under <home>/logs/jobs/. Externally driven session jobs have no runtime
subprocess and therefore no log. A home-scoped sweep removes settled logs after
retain, then evicts the oldest settled logs when the total exceeds
max_total_bytes; queued/running jobs and recently finalized jobs are protected.
Seat logs remain transient. Expect roughly 440 MB/week at this host's observed
rate, though workload output varies. retain has a hard 24-hour floor; shorter
or malformed retention falls back to the 168-hour default.
Raw retained logs are mode 0600 and unredacted on disk. Treat the Gitmoot
home as sensitive. JSONL exports redact known credential patterns best-effort,
but that redaction is not a vault and cannot guarantee removal of every secret.
Runtime Launch Sandbox
gitmoot sandbox probe
sandbox probe prints whether this Linux host can enforce Gitmoot's strict
Landlock launch sandbox and includes the detected ABI. It runs the real hidden
re-exec shim and verifies both an allowed write and a denied outside write;
unsupported kernels return non-zero. Claude/Kimi produce pipeline stages require
this probe to pass and otherwise retain the explicit Codex-only refusal. Codex
produce remains on its native sandbox. Landlock confines filesystem writes but does
not govern network access; network policy remains the runtime CLI's. Wrapped Claude
may write its runtime-owned $HOME/.claude state and
$XDG_CACHE_HOME/claude-cli-nodejs cache; wrapped Kimi may write its runtime-owned
$HOME/.kimi-code state. Apart from runtime state/cache and standard device nodes,
only declared data paths, the disposable workdir, and temp roots are writable.
omp never reaches this wrapper: it is applied by runtime name, to Claude
and Kimi only. omp also does not advertise the produce capability, because its
Bun binary's Landlock interaction is unprobed and advertising an unproven
capability would turn an unknown into a silent stage failure.
Repo And Daemon Status
gitmoot status --repo owner/repo
gitmoot events --repo owner/repo
gitmoot daemon start --poll 30s --workers 1
gitmoot daemon start --session <root-job-id>
gitmoot daemon start
gitmoot daemon status
gitmoot daemon logs
gitmoot daemon restart
gitmoot daemon stop
For structured local state, use gitmoot dashboard --json or
gitmoot task list --repo owner/repo --json. gitmoot status --json and
gitmoot task show are not valid commands.
gitmoot daemon status always prints the configured daemon log path. For a
running daemon, it also compares that file's last write with the daemon's
recorded start time. A missing file or an older last write produces a warning
with both times and suggests
journalctl --user -u gitmoot-daemon -f if the daemon runs under systemd.
gitmoot doctor reports the same confirmed condition as a non-fatal
daemon log check. Fresh, stopped, and indeterminate cases add no output.
Build Skew (Upgraded But Not Restarted)
The daemon is a long-lived process: replacing the binary does not change the
code it is executing. It keeps running the old build until you restart it, which
is easy to miss because gitmoot version describes the binary you just invoked,
not the one the daemon is running.
The daemon records the build it started from in <home>/.gitmoot/daemon.json
(version + commit). gitmoot daemon status prints that build and compares it
against the build of the binary now sitting at the daemon's own path — the one
a restart would load:
build: dev-cd43a49 (cd43a495)
WARNING: daemon running dev-cd43a49 (cd43a495); /root/.local/bin/gitmoot is dev-56ba1c7 (56ba1c74) — restart the daemon to pick it up
gitmoot doctor reports the same comparison as a non-fatal build check. It
compares the daemon against the daemon's own binary — not against whatever
binary you happen to be invoking, which need not be the daemon's at all.
Unknown is never reported as skew — and never as agreement either. The comparison
is skipped when the daemon is not running, when it was started by an older gitmoot
(which recorded no build), or when either side is an unidentifiable build. A
build is identifiable if it was stamped (any release, and the documented deploy
recipe) or if Go's VCS stamping supplied a commit, which a plain go build in a
git tree does. Two unstamped builds with no commit are both just dev:
indistinguishable, so comparing them would prove nothing.
The web dashboard's /api/health reports the daemon's recorded build — what
the daemon process is actually running, not the version of whatever binary now
sits at its path — plus, separately, the serving dashboard process's own build.
Its daemon.versionSource is recorded when daemon.version came from daemon
startup metadata, or unknown when an older daemon recorded no build; in the
latter case daemon.version is empty and must never be treated as either skew or
agreement. This keeps a stale dashboard or daemon visible rather than silently
wrong. The update badge remains relative to the binary on disk, since that is
what an update replaces.
Watched Repos
gitmoot repo add owner/repo --path <path> [--poll <duration>]
gitmoot repo list
gitmoot repo set-interval owner/repo (<duration>|default)
gitmoot repo set-interval --all (<duration>|default)
gitmoot repo remove owner/repo
gitmoot repo doctor owner/repo
gitmoot repo collisions owner/repo [--limit N] [--json]
The gitmoot repo commands manage the watched-repo registry: one daemon
per Gitmoot home supervises every enabled registered repo. Omitting
repo add --poll stores inherit, so the repo follows the daemon's resolved
--poll / [daemon].poll cadence; an explicit value is a per-repo override.
repo list prints inherit. Use repo set-interval with a duration to change
an override, with default to restore inheritance, or with --all to update all
registered repos.
A changes-requested review reports its verdict to the requester and never
creates an implement job: Gitmoot does not dispatch implementation (#2203).
The seat named by --lead (or the requester's own session) does the fix work
and records it with gitmoot job record --type implement.
repo doctor owner/repo checks checkout/config health. If the registered
checkout is missing or is no longer a Git worktree, Gitmoot verifies the
recorded primary checkout, repairs the registration, and reports the self-heal.
Implicit registration from inside a linked task worktree pins the repo to its
primary checkout; an existing valid linked checkout remains usable.
gitmoot repo collisions owner/repo inspects at most the newest --limit open
pull requests (default 25, maximum 100), compares each selected pair's current
changed-filename sets, and prints one warning per non-empty intersection with
both PR numbers and the sorted shared paths. It exits 1 when collisions exist, 0
when every inspected pair is disjoint, and supports structured output with
--json; a clean result is the iterable empty array []. Rename history is not
available, so a concurrent edit of a renamed path's old name may be missed.
Use daemon start for the background daemon. Use daemon run only when the
user explicitly wants a foreground process. Keep the default --workers 1
unless the Gitmoot home has multiple independent runtime sessions or managed
agent types with max_background greater than one.
daemon start --repo owner/repo scopes the daemon to a single repo: it
polls only that repo's PRs and claims only that repo's queued jobs. Omit
--repo to supervise every enabled registered repo from one daemon (#581). Do
not start one daemon per repo on the same home expecting parallel isolation: a
second daemon on the same home is refused (daemon already running with pid …; a stale pidfile from a dead owner is liveness-checked, so restarts work
cleanly). To cap one repo's parallelism on a shared (no---repo) daemon, use
the per-repo config keys below instead.
Both daemon run and daemon start accept --session <root-job-id> (alias
--root) to pin the worker to one orchestration run. With --session set, the
worker runs only jobs whose root_job_id matches that value plus the root
coordinator job itself, and ignores every other queued job. Leaving it empty
keeps the default behavior of matching all jobs.
Both daemon run and daemon start also accept three opt-in flags (all off by
default, so leaving them unset is byte-identical to before): --watch-issues
watches open issues for @<agent> ask … mentions and routes them to jobs,
mirroring the PR-comment watcher; --scheduler pool selects the continuous
worker-pool scheduler that re-queries the queue as workers free and auto-isolates
a contended same-repo read job into an ephemeral worktree (fixing a same-repo
dependent-job deadlock), versus the default --scheduler barrier.
To run a repo's queued jobs N-wide, use --parallel N (sugar for --workers N --scheduler pool; it cannot be combined with --workers or --scheduler).
Raising --workers above 1 without an explicit --scheduler now auto-selects
pool, since multiple workers under barrier serialize same-repo jobs anyway;
an explicit --scheduler barrier is still honored. gitmoot daemon status
reports the live scheduler mode and worker count (e.g. scheduler: pool, workers: 5), and the daemon logs a preflight warning — with the exact relaunch command —
when ≥2 parallelizable jobs are queued under a serializing config. Same-repo
parallelism is bounded by distinct runtime sessions as well as distinct
checkouts; see Run Jobs In Parallel On A Repo.
One repo's concurrency can also be capped from config, without any relaunch,
via a [repos."owner/repo"] section with max_parallel = N (#576) — see
Cap one repo's parallelism from config.
org chart and org status render the provider's last completed turn
alongside seen= (#1702). org status --json carries it as last_turn.
The turn measures PROGRESS; seen= measures RECENCY, and they answer different
questions - a seat can be inside one long turn with a stale note age and be
perfectly healthy. Measured on one host, two seats read 27h and 45h by note age
while each had completed a turn minutes earlier.
last_turn is ABSENT rather than zero when the provider reported no turn
activity, and the text surfaces render - for that case. A reported turn of 0
is a real value and renders as 0; a rendered 0 for silence would invent a
stalled seat. An unavailable role keeps its reported turn, because an
unavailability incident says whether a role may be dispatched to, not whether
the provider reported anything.
The same [repos."owner/repo"] section declares a repo's staged review
verdict agent (#1821):
[repos."owner/repo"]
staged_review_verdict_agent = "gm-review-opus"
A staged review splits one review into a cheap preflight stage that answers
can this review be performed here and a verdict stage whose result is the
review. staged_review_verdict_agent names the agent that would run the
verdict stage.
Local agent review and review-resolved agent run dispatch honor this key:
they run the requested reviewer as a preflight parent. The parent checks
whether the review can run and delegates exactly once to the configured verdict
agent. The preflight is marked so its result cannot be consumed as the final
review verdict. Daemon fanout, heartbeat, pipeline, comment-command, and
externally recorded session reviews do not consult this key; they keep their
ordinary review paths.
It is off by default with deliberately no default and no fallback list.
A repo that has not declared one uses the ordinary, unstaged review path, and
having a [repos.*] section for some other key is not a declaration. A
strong reviewer that cannot be identified must never degrade to the cheap stage
approved it, so the reviewer is an operator's decision per repo rather than a
constant in the Gitmoot source.
Job kill deadlines are independent from stale-running detection. Configure the daemon defaults with:
[daemon]
job_timeout_default = "4h"
job_timeout_max = "8h"
quiet_kill_after = "45m"
permission_policy_observation_enabled = true # off by default
The effective deadline resolves in this order: a positive job_timeout in the
job payload, the review class floor below, the registered agent type's
[agents.<type>].job_timeout, then job_timeout_default. job_timeout_max is
a hard ceiling (default 8h) applied last and clamping every source including
an explicit payload value; a larger request is clamped and the job receives a
job_timeout_clamped event recording the requested and applied durations. This
prevents a delegation tree from granting itself an unbounded run. These keys are
read when a job dispatches, so edits affect newly started jobs without changing
an in-flight deadline.
Reviews carry their own floor, because a review's duration is a property of the prompt class rather than of whichever agent happens to answer it:
[review_router]
job_timeout = "3h" # default 3h; must be positive
A review job gets at least this long even when the answering agent's own
job_timeout is shorter, so rotating between reviewers cannot shorten a review.
A longer agent setting still wins, and an explicit payload job_timeout still
wins over both — a caller that deliberately caps one review keeps that cap. A
job counts as a review when its stored job type is review, independent of
whether the payload carries a review purpose or model pool.
Two advisories report deadline inputs that would otherwise be ignored in
silence: an unparseable or non-positive payload job_timeout lands a
job_timeout_payload_invalid event on the job and falls through to the next
source, and a [review_router] section that cannot be read lands a
review_class_deadline_default event naming the parse error and the deadline
used instead. A non-positive job_timeout in the config file is refused at load
rather than accepted.
quiet_kill_after controls the transcript-silence leg of the liveness
conjunction (default 45m, hard floor 5m). The 30-minute stale-running
threshold remains only an age predicate; neither value is a job kill deadline.
Reconfigure Without Restarting
kill -HUP <daemon-pid> re-reads the [daemon] config section (poll,
workers, scheduler, parallelism, idle_grace_ticks,
idle_max_multiplier, and quiet_kill_after) live (#577) — no teardown, no dropped
jobs, no environment re-inheritance. Values pinned by explicit launch flags win
over the re-read config. Prefer SIGHUP over a restart when only tuning
throughput.
The default-on [disk_guard] section pauses normal queued-job dispatch when the
filesystem holding the Gitmoot home and worktrees has less than either
min_free_bytes (default 2147483648, 2 GiB) or min_free_percent (default
5) available. Both checks apply when both are non-zero, so the more
conservative floor wins:
[disk_guard]
enabled = true
min_free_bytes = 2147483648
min_free_percent = 5
The guard fails closed: an unreadable config, missing path, statfs error, or
invalid filesystem measurement pauses dispatch instead of assuming the disk is
healthy. Paused jobs remain queued and retry automatically on the next healthy
daemon pass. The daemon writes a greppable
DISK GUARD REFUSED JOB DISPATCH log and a
dispatch_refused_disk_guard job event containing the measured free space,
configured floors, and measured path. gitmoot daemon status always reports the
current measurement and prints UNHEALTHY, dispatch paused when the measurement
cannot be established or a floor is breached. The guard applies only to normal
agent-job dispatch; daemon maintenance/reconciliation remains runnable so an
internal reclaim pass can free space.
While the disk is low, remote_reviews = true (default false) lets queued
reviews run on the remote execution backend instead of waiting; every other job
still waits. A review already set to --exec-backend remote runs; a review with
no backend of its own is switched to remote (event disk_guard_routed_remote)
when it has an exact pull-request head, its agent's runtime runs remotely, it
has not used its one cloud attempt, and the [remote_exec] cost cap has room
for one more attempt in dollars and in number (checked in the same database
write as the switch, so concurrent passes cannot overshoot). A switched review
needs green current-head CI at remote admission. If it would fail or block for
any reason before its remote run starts (red CI, a moved head, a duplicate, the
worktree or checkout, the cost cap, the provider), it goes back to waiting
locally (event disk_guard_route_undone) instead, and is not switched again.
The one exception is CI that has not finished: when at least one current-head
check is still pending and none has failed (refusal reason ci_pending), the
review is held for two minutes (blocker_retry_at) instead of declined, and a
later pass switches it to remote again, so it runs once CI turns green. Failing
checks or no reported checks still decline the route. A
review pinned to --exec-backend local keeps waiting, and nothing is switched
while the daemon is draining. It spends money, so it needs a configured
[remote_exec] cost cap and is off unless set.
A queued job that the dispatcher examined and could not claim records a
dispatch_held_back job event naming the reason: an admission-budget refusal
(including the never-fit case, which names the cap), a checkout key held by an
in-flight job, a runtime session lock with its holder and lease expiry, or - when
the pass cannot attribute the wait - the bare fact that the job was not selected
this pass. Before this the reason reached daemon stdout only, so
gitmoot job events <job-id> could not distinguish a job waiting two hours from
one about to start. The event is throttled to one row per job per distinct reason
every five minutes, matching the log line it accompanies.
Claude Runtime Auth
Claude runtime auth lives in owner-only (0600) runtime-auth.env and is re-read
for every adapter build. Manage it without placing secrets on argv:
claude setup-token
gitmoot auth set claude
gitmoot auth status
gitmoot auth probe claude
gitmoot auth unset claude
Rotation takes effect on the next delivery without a daemon restart. Status is local and masked; probe is a paid fresh-session liveness check. When the file selects one managed Claude variable, Gitmoot injects all three names and blanks the absent ones so an ambient API key cannot override OAuth. Unset writes an explicit-empty file; do not delete it, because a missing file can trigger the one-time legacy/environment bootstrap.
Opt Claude into the daemon-owned credential-terminating loopback gateway with:
[credentials]
model_gateway = true
model_gateway_allow_hosts = ["api.anthropic.com"]
Each delivery receives a job-scoped placeholder and ANTHROPIC_BASE_URL; the
gateway holds the snapshotted real credential, forwards only to an allowlisted
host, streams the response, and revokes the placeholder when delivery ends.
Failures are fail-closed. The option is off by default and does not cover Codex
or Kimi.
env-var routing is cooperative, not a hard egress boundary — a malicious agent
can unset it; this buys credential custody/policy/attribution, not enforcement.
The strong "agents never hold real credentials" claim also requires Landlock
read-rules for runtime-auth.env (same-UID read is currently possible) — that
is P3.
GitHub Poll Budget And Idle Cadence
[github].conditional_requests defaults to true. The daemon sends ETag
validators on its four per-tick repository reads and reuses the cached raw JSON
on 304 Not Modified; set it to false as a kill switch. The cache is in memory,
so the first sweep after a process restart is unconditional. The same section's
calls_per_hour_warn is a count-and-warn threshold (0 disables it). It is an
approximate daemon-local sliding-hour count and excludes foreground and
agent-owned gh processes.
After [daemon].idle_grace_ticks all-304 polls (default 3), a quiet repo moves
to 2x and then up to [daemon].idle_max_multiplier (default 4; 1 disables
decay). A miss, poll error, queued job, or in-flight job resets it immediately.
Open-PR repos remain at base cadence because their per-PR comment reads are not
conditional. Decay gates repository GitHub calls only; heartbeat and pipeline
maintenance still wakes at the base interval.
gitmoot dashboard prints a styled one-shot snapshot of local state — daemon
health, repos, agents and runtime sessions, jobs by state, worktrees, and branch
locks. It prints the same snapshot everywhere: terminal, pipe, or CI.
gitmoot dashboard # styled one-shot snapshot
gitmoot dashboard --json
gitmoot dashboard --all
gitmoot dashboard --watch # redraw until Ctrl-C (terminal only)
gitmoot dashboard --watch --interval 2s
gitmoot dashboard --web [--addr 127.0.0.1:8080]
The styled output leads with a "needs attention" block, colors and truncates
long lists, and groups near-identical runtime sessions; --all shows
everything. --watch redraws on an interval (default 5s) and cannot be
combined with --json.
gitmoot dashboard --web serves the read-only web dashboard until
interrupted: a live orchestration/delegation graph plus whole-history Galaxy,
Jobs, Agents, Charts, and Health views with run summaries and prompt/output
inspection — the browser view of a running orchestration.
--addr sets the listen address (default 127.0.0.1:8080). To expose it
beyond localhost, bind to an internal address and put an authenticating
reverse proxy in front — the dashboard itself has no authentication. See the
Dashboard section for the views, routes, refresh
cadences, and mobile support.
Bug Reports
Use gitmoot report bug to build a redacted GitHub-ready issue from local
Gitmoot error state. Job reports are fully supported; daemon, dashboard, and
train selectors are reserved and return clear unsupported-source errors until
their source collectors are implemented.
gitmoot report bug --job <job-id> [--preview]
gitmoot report bug --job <job-id> --create --yes
gitmoot report bug --source daemon --preview
gitmoot report bug --source dashboard --preview
gitmoot report bug --train <session-id> --create --yes
Default behavior is preview. Agents should run preview first, show or summarize
the redacted draft, and create an issue only when the user explicitly asks or
the active workflow policy already permits filing reports. Non-interactive
creation requires --create --yes.
Created reports target gitmoot/gitmoot, include the labels
gitmoot-dashboard-report and bug, and carry a fingerprint marker in the
body so duplicate open issues can be reused instead of creating another report.
If duplicate search fails in the CLI path, Gitmoot prints a warning and still
creates the issue; dashboard creates fail closed and keep the preview open so
the user can retry.
After creation, report the printed issue URL back to the user. If Gitmoot says an existing issue was found, report that URL instead of presenting it as a new issue.
Agent Setup
Start a new runtime session managed by Gitmoot:
gitmoot agent start reviewer \
--runtime codex \
--repo owner/repo \
--path . \
--role reviewer \
--capability ask \
--capability review \
--model gpt-5-codex \
--effort high \
--start-daemon
agent start, agent subscribe, and agent type set accept an optional
--model <name> flag that sets the agent's default runtime model. It is a
free-form, runtime-scoped string (a Codex, Claude Code, Kimi Code, or omp
model name) with no allow-list; both --model X and --model=X are accepted.
A per-job --model (or a delegation's model field) overrides this default,
and an omitted model preserves the runtime's own default. The same default can
be set in config under [agents.<type>].model.
The same commands accept --effort <value> as the agent's default reasoning
effort, and agent run, ask, review, and orchestrate accept it
as a per-job override. A delegation's effort field is the child-job override,
while an ephemeral worker spec's effort is that worker's default. The
resolution order mirrors model selection: job/delegation effort, agent/worker
effort, [runtimes.<runtime>].default_effort, then no explicit override. Values
are free-form pass-through strings. Codex receives
-c model_reasoning_effort=<value>; Claude and Kimi ignore the setting.
agent start and agent subscribe accept --policy (default auto). The policy
maps to the runtime permission mode and decides what a headless job may do:
--policy | Claude --permission-mode | Headless capability |
|---|---|---|
read-only | plan | inspect/report only, no writes |
workspace-write | acceptEdits | file edits only — does NOT unblock Bash (go/git/gh) |
danger-full-access | bypassPermissions | full implementation: file writes plus Bash |
auto (default) | (no flag) | non-deterministic — inherited from ambient Claude config |
For engine-dispatched jobs, adapters also declare applied, widened, or
not-applied as a by-product of building argv. A live agent whose runtime does
not resolve is unresolved; a missing agent row also produces an unresolved
warning for that individual job. Both properties emit a structured, warn-only
job event with runtime, policy, capability, job id, property, and agent name,
coalesced once per agent configuration and capability per 24-hour window. The
job still runs. Ambient runtime config may provide a sandbox Gitmoot cannot
observe, so this event never claims the process was unsandboxed. Interactive
coordinator sessions are unaffected.
At job completion, the one winning sampled job for the coalescing window is
updated in place with checkout_dirty, branch_pushed, and
payload_had_pull_request. Git-derived booleans are
null when the observer could not determine them, and
branch_pushed_instrument identifies the local-upstream check, the ls-remote
fallback, a branchless payload, or an unavailable answer.
payload_had_pull_request says only that the payload already carried a PR
number at capture; it does not attribute opening that PR to the sampled job.
This is effect measurement only: capture errors are logged and never change job
state or delivery.
Because of this, an agent that carries the implement capability must be
started/subscribed with a write policy. Gitmoot fails closed: --capability implement with auto/empty or read-only is refused at agent start, at
agent subscribe, and when --lead names that agent on a review dispatch,
each with an actionable message. Set
--policy danger-full-access for full headless implementation (file writes plus
go/git/gh), or --policy workspace-write for edits-only (Bash stays
blocked). read-only/ask/review agents are unaffected.
--runtime accepts codex, claude, kimi, or omp. Kimi Code is
a first-class runtime adapter alongside Codex and Claude Code; kimi targets the
current Kimi Code CLI (stream-json output).
Before starting a Kimi-backed agent, authenticate the Kimi CLI with
kimi login, then restart the Gitmoot daemon so it inherits the logged-in
session:
kimi login
gitmoot daemon restart
gitmoot agent start reviewer \
--runtime kimi \
--repo owner/repo \
--path . \
--role reviewer \
--capability ask \
--capability review
A Kimi agent's runtime reference must be a Kimi session id (session_<uuid>)
or empty; Gitmoot parses the session id from the Kimi CLI's stream-json output.
omp is the oh-my-pi CLI (#1428): a multi-provider routing harness rather
than one vendor's CLI, so which provider answers is a property of the omp profile
the daemon runs under. What that changes in practice:
- Capabilities are
review,implement, andask— notproduce. - Every job is a fresh session. omp is stateless in v1 and never resumes,
because
omp --resumerelocates the working directory to the previous session's cwd and overwrites--cwd: a resumed job would edit the old worktree and leave the job's own worktree clean, which surfaces as a green job with an empty diff. A--sessionref may be a session UUID orfresh:<suffix>, neverlast. - Exit code 0 is not success. Under
--mode=jsonomp exits 0 even on a failed turn, so Gitmoot decides success by parsing the NDJSON stream; an empty answer, a truncated stream, one that ends mid-retry, or a run cut off mid-work — by the--max-timedeadline or the provider's output cap, where the envelope is complete and only the final answer is missing — fails loudly rather than reporting an empty success. --policyselects the ordinary--approval-modevalue, and the flag is always present for determinism - omitting it would inherit whatevertools.approvalModethe host config carries.read-onlymaps toalways-ask,workspace-writemaps towrite, andautoplusdanger-full-accessmap toyolo. Measured on omp 17.2.4 headless,always-askletsread/grep/globsucceed and refusesbash/write, with the process exiting 0 and a fullagent_end, so it restricts omp rather than breaking it (#1721). A kernel-enforcedReadOnlySeatis the exception: Gitmoot wraps the delivery in Landlock and passesyoloso headless shell and test tools can run. The adapter declares that overridewidened; Landlock remains the write boundary. Non-seatread-onlykeepsalways-ask. The other three explicit policy mappings are declaredapplied, whileautoiswidened.- OMP runtime-family diversity uses runtime-reported upstream-provider
evidence. A successful delivery whose final runtime message identifies its
provider and model records that provider in the append-only event ledger; the
OMP wrapper remains
effective_runtime=omp. Providers shared with native adapters compare as the same family:openaiandopenai-codexmap to Codex,anthropicmaps to Claude, andkimi-codemaps to Kimi. Providers without a native adapter stay namespaced, for exampleomp:devin. Different models and agent names on one provider remain the same family. The comparison is advisory, not an independence gate: same-family comparisons emitmerge_gate_family_advisory, while unresolved comparisons retain themerge_gate_family_unresolvedevent; neither disqualifies a substantive approval from a reviewer whose identity is not an implementer. For a review fan-out, every coordinating parent is an announcement rather than a verdict, including nested fan-outs. The gate applies identity and family checks to each approving leaf and records approval evidence on that leaf. Requested model text alone and failed delivery are not provider evidence. - Authentication is per profile. Authenticate omp once interactively, export
the provider key the daemon should use, or point it at an auth broker; then
restart the Gitmoot daemon so it inherits the credential. The daemon must also
see the binary — its
PATHcomes from the systemd EnvironmentFile, not a login shell.
Full behavioral contract (argument vector, retry semantics, usage source, transcripts, curated environment): Runtime Adapters.
Subscribe an existing runtime session:
gitmoot agent subscribe reviewer \
--runtime codex \
--session <session-id-or-last> \
--repo owner/repo \
--role reviewer \
--capability ask \
--capability review \
--model gpt-5-codex \
--effort high
agent subscribe additionally accepts --runtime shell, the deterministic
no-LLM adapter whose --session is a command (the job prompt arrives as
$1; stdout must carry the gitmoot_result envelope) — useful for
deterministic end-to-end tests.
Inspect and manage agents:
gitmoot agent list
gitmoot agent show reviewer
gitmoot agent show reviewer --json
gitmoot agent repos reviewer
gitmoot agent allow reviewer --repo owner/other-repo
gitmoot agent deny reviewer --repo owner/other-repo
gitmoot agent doctor reviewer
gitmoot agent restart reviewer
gitmoot agent remove reviewer
agent policy: change a registered agent's autonomy policy in place
gitmoot agent policy <name> --policy auto|read-only|workspace-write|danger-full-access
This writes the plane dispatch reads. agent type set --policy writes the
config plane only, and most registered agents have no config section at all, so
for them it refuses outright. agent start and agent subscribe carry
--policy but mean re-registration, which replaces the runtime session.
Dispatch resolves a registered agent through GetAgent, which reads the
agents row if one exists and otherwise the agent_instances row. This verb
updates both rows in one transaction rather than only the one that outranks
the other today. A same-name agent_instances row can OUTLIVE the agents row
that outranked it, because agent remove deletes agents and not
agent_instances; a stale instance left behind would then become authoritative
and silently re-widen an agent you had tightened. Only a name present in
neither plane is unregistered.
When a config type also exists, both planes are written, and the command reports each:
agent: appkit-omp
policy: workspace-write -> danger-full-access
config_type: danger-full-access # or: none (registry-only agent; nothing to keep in step)
Failure semantics are all-or-nothing, enforced by a held transaction. Both the agents row and the config type can authorise dispatch on their own: explicit managed-type routing never consults the row. So no ordering is safe, because whichever plane is written first is already live before the second can fail. The database transaction is the coordinator instead. The rows are updated, the config write runs as a barrier inside that still-open transaction, and the commit lands only if the config write succeeded. A config failure rolls the rows back, so nothing moved on any plane. If the commit itself fails after config succeeded, the config write is rolled back too and the command reports that nothing changed; only if that rollback also fails does it report the planes as inconsistent, naming both values so you know what to correct.
Every plane that can become effective is written, not only the one that
decides today: a same-name agent_instances row is updated alongside the
agents row, because agent remove deletes only the latter and a surviving
instance would otherwise silently re-widen an agent you had tightened.
agent type show returns non-zero when a registered agent's policy differs
between planes, or when the effective policy cannot be determined; it prints
policy_effective: alongside the config policy: so neither a human nor a
stdout parser can read the config value as the effective one.
gitmoot agent show <name> keeps the existing runtime_ref: <id> line unchanged
and makes concrete session pinning explicit on a separate
runtime_session: pinned (last successful use: <age>) line. The extra line is
omitted for an unpinned runtime_ref: last. The age comes from the newest
succeeded job whose newest effective_runtime or runtime_override event
resolved to that exact runtime and session. An event for another runtime with
the same ref does not count, and an older event from a failed attempt is ignored
when the same job later succeeds on a retry. A pin with no matching successful
job prints last successful use: never. JSON keeps the raw runtime_ref
unchanged and adds runtime_ref_pinned plus, only for a pin,
runtime_ref_last_successful_use.
gitmoot agent restart <name> abandons the agent's runtime session and binds a
fresh one in place — the fix for a dead or stranded session that would
otherwise tempt a re-register. It refuses while the session is live or the
agent has in-flight jobs (finish or cancel those first). gitmoot agent remove <name> unregisters the agent.
Delegate to a registered agent from the current local chat:
gitmoot agent run project-planner --repo owner/repo "Return the plan status."
gitmoot agent run reviewer --repo owner/repo --pr 12 --lead lead --background "Review this PR."
gitmoot agent review reviewer --repo owner/repo --pr 12 --lead lead "Review this PR."
gitmoot agent ask project-planner --repo owner/repo "Return the plan status."
gitmoot agent ask project-planner --repo owner/repo --background "Write the implementation plan."
gitmoot agent run reviewer --repo owner/repo --pr 12 --model gpt-5-codex "Review this PR."
gitmoot agent run reviewer --repo owner/repo --pr 12 --effort xhigh "Review this PR."
gitmoot job watch <job-id>
Gitmoot does not dispatch implementation by any route a user can reach
(#2203). There is no agent implement verb, no --action implement, and no
--task/--base/--draft/--ready implement flags; an implement leg in a
result's delegations[] is refused with the action named, /gitmoot <agent> implement is not a recognized PR-comment command, and an implement heartbeat
is refused at config validation. The one remaining exception is the pipeline
action: implement stage kind, which still VALIDATES but has no allocator to
run in; whether pipeline add should refuse it is tracked in
#2213.
What was removed in each case is the writable-worktree ALLOCATION plus the enqueue — precisely what would have to come back for dispatch to return. The removal followed the traffic rather than leading it: 568 of 570 implement rows in a 14-day window were seats recording their own sessions, delegation-origin implement legs total 47 lifetime with zero in the last 30 days, no PR comment has ever carried an implement command, no heartbeat is configured with that action, and no pipeline has ever created an implement stage (lifetime stage jobs: ask 5,229, produce 92, implement zero). Seats implement in their own session and record the work themselves:
gitmoot job record --agent lead --repo owner/repo --type implement \
--decision implemented --pr 12 --head-sha <sha> \
--title "Fix the findings on #12" --summary "What changed and why."
A changes_requested verdict therefore reports to the requester and to the
--lead seat; it never mints a fix job.
agent review and review-resolved agent run with --org-role queue the job
for daemon ownership by default. This keeps a review running if the calling
seat or its command runner exits. Use --foreground only when synchronous
ownership is intentional. Unattributed reviews retain their synchronous
default; --background remains accepted.
agent run --action ask|review explicitly selects the job action and wins
before the usual inference order (--pr/review --head-sha -> review, then
message heuristics, else ask). --type <name> has a separate meaning: it
selects a managed agent type. The flags can be used together. Invalid actions
and contradictions are rejected before enqueue; notably, --action review
requires --pr, and implement is no longer an accepted action.
Review admission is evidence-gated before Gitmoot creates a new review job.
For local agent review / agent run requests that resolve to review, and for
native engine review fan-out, Gitmoot reads succeeded review jobs for the same
repository and pull request. If every succeeded verdict at the requested exact
head agrees on one decision, dispatch is refused: the local CLI returns a hard
error, the engine blocks the task, and the matched succeeded job receives one
idempotent review_loop_detected event. A new head proceeds, and mixed decisions
at one head also proceed because the earlier claim is unstable. The loop guard
allows an empty head only before any succeeded review history exists for that
repo/PR; after that it fails closed until the caller supplies the current head.
The local CLI still requires a concrete head before dispatch. Each admitted
local review gets its own detached, read-only per-job worktree at that exact
commit; the stable per-PR Task row remains lifecycle metadata and no longer owns
the review checkout. Consequently, gitmoot task list shows an empty worktree
column for review Task rows. This is expected: each review job owns its own path,
so there is no single Task-level worktree path to display. The requested
head_sha stays on the job payload. Review
allocation fails closed rather than falling back to the registered checkout,
and dispatch refuses before Task mutation when the Gitmoot filesystem has less
than 5 GiB free or free-space measurement is unavailable. The prior verdict is
escalation evidence only and is never served as the new result.
A review dispatch is also bound to its own head by its PROMPT. Before the
read-only worktree is allocated and before any job row exists, Gitmoot resolves
every commit-shaped token in a review's instructions and classifies it against
the dispatch head: the head itself, an ancestor of it (which covers prior heads
on the branch and the branch base), a head this pull request recorded at some
earlier dispatch, or none of those. Only the last refuses, with a non-zero exit
naming both SHAs and, when the store knows it, the pull request whose head the
cited commit actually is. --allow-prompt-head-mismatch dispatches such a
request deliberately.
Three arms allow on purpose, because a prompt naming another commit is usually how it states provenance. The recorded-head arm reads an append-only store fact rather than git, so a legitimate prior head still passes after a force push has left it unreachable. A citation whose relationship cannot be established at all, because the dispatching checkout resolves neither commit, also dispatches: that is a fact about the checkout rather than about the citation.
prompt_head_warning IS still emitted for a review, for two relations rather
than one. The normal-path relation is a RECORDED PRIOR HEAD THAT IS NOT AN
ANCESTOR. The second is a citation whose ancestry the classifier could not
establish: promptCitationAncestry returns known=false when IsAncestor
errors, and that lands on promptCommitUnresolved, which the filter also
retains. That arm is an INSTRUMENT FAILURE rather than a statement about the
prompt, and it is deliberately reachable - when the classifier cannot judge a
citation, nobody has judged it.
Retention is PER CITATION: a warning is kept only when the retained relation is
the one that warning is about, matched on its leading
prompt references commit <token>, clause. That is what makes the two relations
above exhaustive - an earlier version tested for the token anywhere in the
warning text, and since every warning names the dispatch head twice, an unjudged
token that happened to be a hex run from inside that sha could retain a
DIFFERENT citation's warning.
Two things are silent. A token the scan itself could not resolve produces no
warning of its own, because dispatchPromptHeadContradictionWarnings discards it
before any classification. And on the resolvable path ancestry is checked BEFORE
the recorded-head arm, so a commit that is both a prior head and an ancestor is
silent - the ordinary scoped re-review, citing its own previous head.
Ancestor provenance is silent, because naming the branch base is how a prompt
states where the work sits. The prior-head arm is the force-push shape - the
commit really was a head of this pull request and no longer is - so a prompt
naming it as its target is reviewing a tree that is gone, and that is worth one
line to its operator even though the dispatch proceeds. Ask and implement keep
the blanket warning, because no refusal runs in front of them and it is their
only head check.
--head-sha must be the FULL 40 hex characters for a review. An abbreviated
value used to dispatch and then be cancelled by the daemon's staleness check,
which compared it against the pull request's full head and reported the same
commit as a move; it is now refused at dispatch, before the read-only worktree,
the review task row and the job row. The refusal rejects EVERY non-empty value
that is not exactly 40 hex characters, including a revision expression such as
<sha>^ or <sha>~1: the daemon binds this value by EQUALITY against the pull
request's head, so no other shape can bind whatever it looks like. It runs after
the review-loop and reviewer-identity refusals, so those still name their own
preconditions first, and before the review task is upserted, so a refused
dispatch leaves no durable state behind.
--no-fix-target dispatches a REVIEW-ONLY review: the reviewer need not be able
to implement and no --lead is required. The lead exists so a
changes_requested verdict has an implementer to route to, so declining one is
a decision, and it is recorded as a review_no_fix_target job event stating
that the dispatching operator owns the follow-up. An UNSTATED absence still
refuses, and --no-fix-target with --lead is refused as mutually exclusive.
This exact (repo, PR, head_sha, decision) evidence key is intentional: the
#1419 review panel rejected round counters and other instruments, so this guard
does not infer a loop from a numeric threshold. Direct PR-comment review ingress
is unchanged here and remains advance-time guarded until #1433; cached-verdict
serving remains out of scope for #1415/#1423.
Local review dispatches accept --lead <implementer> on agent review and on
agent run when it resolves to review. A changes_requested verdict routes its
fix WORK to that lead, not to the reviewer: Gitmoot wakes the lead's org role
and never mints a fix job (#2203). Before creating a review job or
starting its runtime session, Gitmoot loads the lead from the agents database
and requires that it exist, can access the repository, has implement
capability, and uses a write-granting policy (workspace-write or
danger-full-access). Without --lead, the reviewer is the fallback lead and
must pass the same checks. Managed-type review dispatches also require an
explicit DB-backed lead. --lead is rejected whenever agent run resolves to
anything but review, and is not accepted by orchestrate.
A strict review-only agent therefore needs either an explicit implementer or
--no-fix-target, which declares that this review has NO fix target and the
dispatching operator owns the follow-up. That declaration is carried on the job
payload, not only as an event, so the ledger records who owns an open
changes_requested finding: Gitmoot dispatches no fix either way (#2203), and a
review with no lead has no org role to wake, which is exactly what the operator
is taking on. --no-fix-target is rejected outside review and is mutually
exclusive with --lead.
This dispatch-time lead validation applies only to local CLI reviews started by
gitmoot agent review or review-resolved gitmoot agent run. Reviews routed
from PR comments continue to validate their fix target when the workflow
advances until gitmoot#1433
adds the corresponding ingress preflight.
When an engine review returns changes_requested, Gitmoot persists the verdict
and wakes the requester's org role without creating an implement job. That is
the terminal behaviour, not a default: there is no per-PR opt-in that converts a
verdict into a fix dispatch, and no writable fix clone is allocated (#2203).
The lead implements in its own session and records the work with gitmoot job record --type implement — the rows the merge gate's implementer attribution
and reviewer-independence check read.
Before delivery, these dispatch commands scan commit-shaped tokens against the
target repository. Ask preserves its existing scanner input;
review scans its newly allocated exact-head worktree with the requested head
still bound. If a token resolves to a commit other than the dispatch head,
Gitmoot prints an advisory warning such as
prompt references commit <referenced>, but the dispatch head is <head>; Gitmoot will use dispatch head <head>. The job still runs because prompts may
legitimately discuss historical commits. Hex strings that do not resolve to a
commit, including mutation-hygiene SHA-256 restore hashes, do not warn.
The scan covers the instructions you pass on the command line, and nothing
else. Text contributed by a recipe template (--recipe) or by the selected
agent's own template is not scanned, so a stale commit cited inside a template
body dispatches with no warning. If you drive dispatches through templates,
treat this warning as covering your instructions only.
A PR the forge reports as a DRAFT is an author-controlled hold: it does not move
its task to awaiting_human_merge, because no human merge decision has been
requested. Marking the PR ready for review lets normal merge-gate advancement
resume, and an unknown draft state fails toward NOT parking. Gitmoot no longer
opens implementation PRs itself and the --draft/--ready dispatch flags went
with agent implement (#2203), so this hold now describes whatever PR the gate
observes — including one a seat opened by hand.
A repository's default branch comes from the registered checkout's local
origin/HEAD symbolic ref. Git does not refresh that ref when the upstream
repository renames its default branch. Run git remote set-head origin -a in
the registered checkout after such a rename; until then Gitmoot treats the
cached ref as authoritative and may reconcile repos.default_branch back to
the stale name.
gitmoot agent run, ask, and review (and orchestrate) accept
an optional --model <name> flag that pins the runtime model for that one job,
overriding the agent's configured default. It is a free-form, runtime-scoped
string (a Codex, Claude Code, Kimi Code, or omp model name) with no allow-list;
an omitted --model leaves the agent's default model in effect. Both --model X
and --model=X are accepted.
--effort <value> and --effort=<value> select reasoning effort for one job
with the same job-over-agent-over-registry precedence. Gitmoot does not validate
an allow-list; Codex validates the forwarded value.
The same commands accept an optional per-job --runtime codex|claude|kimi|omp|shell override: that ONE job runs through the
named runtime while the agent's registered default runtime stays untouched
(agent show is unchanged afterwards). An overridden job never resumes — and
never writes back to — the agent's default-runtime session: it runs on a fresh
session of the override runtime, or on an explicit --session <ref> (a
Codex/Claude session id, a Kimi session id, an omp session UUID or
fresh:<suffix>, or — required for shell — a command; last is rejected
because it resumes whichever session is most
recent rather than a concrete one), and its runtime-session lock names the
override runtime so it cannot collide with the default session's lock. Model
rule: --model combined with --runtime is interpreted for the OVERRIDE
runtime; an override without --model uses the override runtime's default
model — the agent's configured default model is never applied to a different
runtime. The same rule applies to --effort: an explicit job value belongs to
the override runtime, while the agent's default effort does not cross runtimes.
An unknown
--runtime fails before any job is enqueued, background (daemon) jobs honor
the override identically to foreground, and a coordinator's delegation-tree
continuations (synthesis, corrective, replan, finalize) inherit the override,
so an orchestrate --runtime tree stays on the override runtime across
generations:
# Retry a hard review through Claude without re-registering the reviewer:
gitmoot agent review reviewer --repo owner/repo --pr 123 "Re-review this PR." --runtime claude
gitmoot agent ask reviewer "Compare the approaches." --repo owner/repo --runtime kimi --model kimi-k2
# Route one job through omp's router; --effort becomes omp's --thinking level:
gitmoot agent ask reviewer "Summarize the risk in this diff." --repo owner/repo --runtime omp --effort high
gitmoot orchestrate and agent run also accept an optional
--skip-native-review-fanout flag. It states that the coordinator owns review
orchestration, so a PR on this branch must not be fanned out to Gitmoot's
native reviewers (the configured required reviewers, or the ones passed for the
task). The flag is persisted on the job payload and on the branch lock, and the
daemon's GitHub PR-watcher reads the lock, so a PR it observes on that branch
stays free of native review fan-out. The engine's implement-advance arm reads
the same flag, but no implement job can be dispatched while #2203 stands, so
the PR-watcher is the path that still exercises it. The flag defaults off;
leave it off for the full native review fan-out.
When a synchronous agent run/ask/review/orchestrate job
delivers and succeeds terminally but a benign post-success advancement step
errors — for example a merge-gate block on the freshly-opened PR, or a 422
"a pull request already exists" race — the command no longer discards the result.
It exits 0 with the agent result on stdout (in JSON mode this includes an
additive advance_error field carrying the advance warning, omitted when there
is none), prints advance warning: … to stderr, and shows an advance_error:
line in human output. Genuine non-terminal failures (the job did not reach
succeeded) still exit non-zero as before. A normal success with no advance error
is byte-identical to prior behavior — no advance_error field is emitted.
Review resilience under branch churn. Newly dispatched local reviews are
pinned to the requested PR head in a per-job worktree, so a later branch push or
registered-checkout movement cannot change what they review. The daemon keeps
the older re-sync behavior only for legacy/fallback review jobs that lack an
owned read-only worktree: when their shared checkout advances and the PR remains
open on the same branch, it re-targets the payload and records
review_head_resynced; closed/merged, dirty, or wrong-branch checkouts fail.
A re-target requires the checkout head to have the dispatched head as an ancestor, so a review queued before an amend or a rebase force-push will not follow the branch: it refuses (recording review_head_resync_refused) and keeps the original wrong-head error, which a non-delegation review job defers and auto-retries within the shared blocker budget before failing terminally, while a delegation-child leg (a high-risk lens child, say) is routed terminally by its own DAG on the first tick — either way the remedy is to dispatch a new review at the new head, which is what exact-head review does anyway.
Any dispatched head that git resolves to the checkout's own commit — an abbreviation of any length, a case-differing 40-character SHA, a rev expression, a ref name — is recorded as review_head_normalized and is never a re-sync, while a dispatched head this checkout cannot resolve at all leaves the re-sync refused and keeps the original wrong-head failure, deferrable on the same non-delegation terms.
Relatedly,
when a foreground agent review finds the agent's serialized runtime session
busy, the review is now left queued for the daemon to run when the session
frees (a requeued_runtime_busy event is recorded) instead of being cancelled and
dropped; agent ask stays synchronous and keeps its existing
busy-session cancel behavior.
Start an orchestra of agents with gitmoot orchestrate:
gitmoot orchestrate project-planner "Plan and split this work across agents." --repo owner/repo
gitmoot orchestrate project-planner "Plan and split this work." --repo owner/repo --model gpt-5-codex
gitmoot orchestrate project-planner "Plan and split this work." --repo owner/repo --effort high
gitmoot orchestrate project-planner "Review PR #123 in this repo." --repo owner/repo --recipe review-panel
gitmoot orchestrate <agent> "..." [--repo R] [--recipe id] is sugar for
gitmoot agent run <agent> --background "...". It starts a conductor
(coordinator) that returns a delegations[] score; the players (child agents)
then run in parallel or in dependency order, and a finale (continuation)
reconvenes and synthesizes the results.
--recipe review-panel|verifier (#477, also accepted on
agent run) routes the coordinator through a named built-in recipe prompt
without changing the agent's identity — see
Coordinator Recipes.
This uses the same agent registry, repo access grants, cached template snapshot,
runtime adapter, and local job history as PR-comment jobs. agent run is the
default coordinator-safe entrypoint because it routes to ask or review and
keeps branch, worktree, commit, push, PR, and workflow lifecycle
inside Gitmoot. agent ask is for analysis, planning, and questions only; it is
read-only, so when the message reads like branch/commit/push/PR orchestration it
prints a non-fatal note and still runs (pass --force to suppress the note).
The runtime
plugin helps Codex or Claude Code discover Gitmoot guidance, but it does not
replace the Gitmoot CLI. Synchronous jobs and queued jobs both use the same
runtime session locks.
Configure managed background agent types:
gitmoot agent type list
gitmoot agent type show planner
gitmoot agent type set planner --runtime codex --template planner --max-background 2 --idle-timeout 20m
gitmoot agent type set planner --model gpt-5-codex
gitmoot agent type set planner --effort high
gitmoot agent gc
agent type set --model <name> (or [agents.<type>].model in config) sets the
default runtime model for that managed agent type.
agent type set --effort <value> (or [agents.<type>].effort) sets its default
reasoning effort.
Schedule recurring agent work (heartbeats, off by default):
gitmoot agent heartbeat add repo-maintainer daily-status \
--repo owner/repo --interval 24h --prompt "Daily status report." --enabled
# --runtime pins a runtime for this schedule.
gitmoot agent heartbeat add reviewer stale-prs \
--repo owner/repo --interval 12h --action review --runtime codex \
--prompt "Review stale open PRs and summarize blockers."
gitmoot agent heartbeat list [--agent <agent>]
gitmoot agent heartbeat show repo-maintainer daily-status
gitmoot agent heartbeat enable|disable repo-maintainer daily-status
gitmoot agent heartbeat remove repo-maintainer daily-status
A heartbeat enqueues a normal background job on its interval. The actions are
read-only: ask (default) and review (review needs the agent's review
capability). implement is refused at config validation (#2203) — the
heartbeat's writable-worktree allocator and its enqueue are gone, so an
implement schedule could only have produced a branchless job with no checkout
to resolve. Schedule read-only work and let the seat record its own
implementation with gitmoot job record --type implement. An optional
--runtime codex|claude|kimi|omp runs the scheduled job on that runtime (fresh
session) instead of the agent default — the accepted set is derived from the
adapter registry, so omp joined it the moment the runtime was registered, and
an enabled heartbeat pointed at omp spends whatever credential its profile
resolves. gitmoot daemon status surfaces each
schedule's last-run/next-due/last-status. See
Heartbeat Schedules for the
full reference.
A registered single instance shadows a managed type of the same name:
dispatch resolves gitmoot agent <name> to a registered single instance before a
type, so force the type with --type <name> (or do not register a single
instance of that name). Since v0.5.1 a foreground gitmoot agent ask <type>
(the ask action) dispatches to the managed type synchronously; background
run/review to a type and [parallel_sessions] temp-session
forking use the background path. See Running one agent's jobs
concurrently.
Review Router
gitmoot review request is the front door for asking for an independent review.
The requester names the pull request; Gitmoot picks the reviewer, runtime and
model, deduplicates on the exact head, and wakes the requester when the verdict
is saved.
gitmoot agent review now routes THROUGH this command. It used to dispatch
beside it, which is why the router's machinery went unused: measured 2026-09-16,
687 of 688 reviews on one box came through agent review, so exact-head dedup,
the delta baseline, availability-aware runtime choice and the verdict wake were
reachable in principle and unused in practice. The lower-level form keeps its
own surface — it NAMES a reviewer, carries a review message, and takes
--lead — and those inputs are forwarded rather than discarded. --json no
longer forces the direct path: agent review --json routes too and prints the
router's JSON object described below.
gitmoot review request --pr 2170 [--repo owner/repo] [--purpose code|security|ui|architecture] \
[--head <40-hex>] [--branch <name>] [--role <org-role>] [--ttl 12h] [--reviewer <agent>] \
[--runtime <name>] [--exec-backend local|remote] [--exec-provider e2b|sandboxd|sandboxd-linux] [--model <provider/model>] \
[--effort <level>] [--workflow <id>] [--session <ref>] [--lead <implementer> | --no-fix-target] \
[--full] [--post-merge] [--allow-prompt-head-mismatch] [--background] [--json] \
[-- "review instructions"]
gitmoot review status --pr 2170 [--repo owner/repo] [--json]
gitmoot review level --repo owner/repo --pr 2170 [--json]
--lead names the implementer a changes-requested verdict routes to; with no
--lead the request dispatches with no fix target and says so. The positional
message is appended to the router's own brief under a labelled header, so a
reviewer can tell operator instructions from generated framing — pass it after
-- if it starts with a dash.
Scripts should use gitmoot review request ... --json to get the job id:
gitmoot review request --pr 2170 --repo owner/repo --head <40-hex> --role <org-role> --no-fix-target --json
The JSON object has the keys agent review --json prints — job_id, repo,
agent, action, execution_path, awaited_fact_id, subscription_holds,
watch_command, daemon_running — plus the router's own: reviewer, model,
runtime, head_sha, holds. state says what the router did
(dispatched, attached or verdict_exists); the job's own state, such as
queued, is in job_state.
--head-sha, --org-role, --no-fix-target and --background are the
agent review spellings and are accepted here, so a script can switch commands
without renaming flags. --no-fix-target is what a request without --lead
already does, and is refused together with --lead. --background changes
nothing: the router always queues the review for the daemon.
--exec-backend local|remote selects where this review's runtime executes. It
is persisted on the job before enqueue and affects no other queued or future
job. Omit it for local execution; process-wide [remote_exec].backend does not
reroute reviews. Remote reviews currently support only shell and omp;
Gitmoot refuses any other runtime/backend pair before enqueue and names both
operands. agent review forwards the same flag through the review router.
--exec-provider e2b|sandboxd|sandboxd-linux picks the remote provider for this review and
implies --exec-backend remote. e2b, the default, is cloud E2B; sandboxd
runs the review on the Mac Studio's sandboxd and requires a
[remote_exec.sandboxd] section; sandboxd-linux runs it on the Gitmoot
host's Firecracker sandboxd and requires [remote_exec."sandboxd-linux"]; otherwise the request is refused before
enqueue. The removed mac provider name is refused, naming sandboxd. The
choice is stored on the
job and kept across retries. A repository with checks_backend = "remote" in
[repos."owner/repo".review] sends every review to its checks_provider and
checks_template, whichever producer enqueued it (this command, PR fan-out,
comment, heartbeat or pipeline reviews); a review without --exec-provider
adopts checks_provider, and --exec-backend local or a different
--exec-provider is refused (see the remote execution reference).
agent review forwards the same flag.
Review levels (#2265)
gitmoot review level decides whether a pull request head needs a review
before merge. It prints level, head and reason, and posts an informational
gitmoot/review-level commit status on that head:
| Level | Meaning |
|---|---|
level1_no_review | Merge and deploy once CI/tests pass. |
level2_background | Merge and deploy once CI/tests pass, then run gitmoot review request --pr N --head <head> --role <role> --post-merge. |
level3_required | One independent review at the exact head before merge. |
Rules, first match wins:
gitmoot/gitmootis alwayslevel3_required.- A fixed path is always
level3_required: agent instructions, CI workflows, deploy config, dependency locks, migrations, the merge gate, or the credential gateway. - With no
OPENROUTER_API_KEY(env or keychain), a classifier error, or anuncertainanswer, the result islevel3_required. - When JEV (
typesafe/jev-1.13) puts the chance of a high-impact change above 0.35, the result islevel3_required. High-impact means security or permissions, unrecoverable data, deploy/merge/review rules, spending money or contacting outside people, or weakened tests. - A truncated diff is never
level1_no_review.
Re-run it whenever the head moves. The status is advisory: it does not block a merge.
Every decision is also appended as one JSON line to review-levels.jsonl in the
Gitmoot home (~/.gitmoot/, mode 600): time, repo, PR, level, source, reason,
head, and JEV's risk probability and choice when it was asked. The commit status
keeps only the latest decision per head; this file keeps them all, so decisions
can be counted by level and by rule. A failed write is a warning and does not
change the decision. On a classifier error the reason holds the error text (at
most 300 characters, API key redacted), the same text the command prints.
--post-merge reviews an already-merged head. Its findings become follow-ups for
the requesting role instead of blocking anything. The verdict still wakes the role.
- P0/P1: a note to fix or revert the same day.
- P2: an issue labelled
review-p2andagent:<role>, plus a note. - P3: no follow-up.
Job events record post_merge_followups_done, or post_merge_followup_error
if a follow-up could not be filed. --post-merge refuses --lead. Through
agent review it needs the router path: --org-role and --no-fix-target,
without --foreground.
Two things agent review reports that are easy to miss:
- A dispatch that cannot be delegated RECORDS why. No
--org-role(the router requires a verdict recipient),--foreground, or any flag the router cannot express (--type,--recipe,--skip-native-review-fanout, an--actionother thanreview,--no-fix-targettogether with--lead, or neither--leadnor--no-fix-target) keeps the direct path and writes arouter_bypassedjob event naming the reason — read it months later withgitmoot job events <id>(NOTjob show, which prints the row and its payload and never lists events). The unexpressible-flag case also prints the reason to stderr; the other two arms never NAME the reason on stderr, which is exactly why the durable event exists — they may still emit unrelated warnings there, so stderr silence is not the discriminator. A silently different review is worse than a refused one. Counting unrouted reviews:router_bypassedalone UNDERCOUNTS, becauseagent run --action reviewandorchestrate --prnever reach that gate and record their reason inroute_selectedinstead. The complete query isroute_selected NOT LIKE '%review_request%'. - An ATTACHING request is told what it lost. A second request at a head that
already holds a claim attaches to the running review rather than spending a
second reviewer — and prints which of your
--reviewer, instructions,--lead,--modelor--runtimewere discarded, because a caller told only that it is "awaiting a verdict" would wait on a review it never commissioned.
--runtime overrides the omp pin. The router pins omp because it SELECTS the
reviewer, so the runtime is its choice rather than an agent's identity — but a
pin with no escape is a dead end: a role carrying a runtime-scoped
org_role_unavailable hold is refused dispatch when the hold names the runtime
it selected, and the caller has nothing else to reach for. gitmoot agent review takes the same flag and pins nothing, because a REGISTERED reviewer's
runtime carries its auth profile and session.
What one request does, in order:
- Resolves the pull request's current head (or binds
--head, which must be the full 40 hex characters) and the requester role (--role, defaultGITMOOT_ORG_ROLE; it must exist in the organization chart because it is the notification destination). - Claims
(repo, PR, head, purpose)inreview_requests. THE CLAIM ROW, NOT THE JOB ROW, IS THE MUTUAL EXCLUSION: dispatch does real work (read-only worktree allocation, runtime preflight, GitHub reads) before the job is enqueued, so for a few seconds the winner holds a claim whose job id is not yet readable. A requester arriving in that window returnsstate: attachedwithjob_state: dispatchingrather than concluding the holder is dead. Takeover asks LIVENESS, not elapsed time: the claim records the dispatching process (pid, its/procstart-time identity, and the boot id), so a claim is taken over when its job ended without a verdict, when that process is provably gone, or when the claim was recorded on an earlier boot. A dispatch that is merely slow — a cold PR-ref fetch, a stopped process — keeps its claim; a time bound applies only where liveness cannot be evaluated at all. A syntheticfailedresult, which the daemon's dead-runtime recovery writes, is NOT a verdict, and neither is a verdict stored on a job that did not succeed: the awaited fact is satisfied only from a succeeded transition, so the router refuses to call anything elseverdict_exists. A claim standing on a realapproved/changes_requestedverdict returnsstate: verdict_existsand spends nothing. A claim whose job DELEGATED the answer — a staged-review preflight whose own result is a fan-out — is held while its verdict child still runs. A different--purposeis a different question and runs in parallel: review-loop detection matches PURPOSE as well as agent and head, so a security request is not refused because a code review already happened — including with one eligible reviewer or an explicit--reviewer, where substituting a different agent is not an option. A repeat of the SAME purpose by the same agent at the same head is still refused, and everything outside the router carries no purpose on either side, so its comparison is the historical one. - Selects a registered agent with the
reviewcapability, WITHOUTimplement, scoped to the repository (omp-native agents first, then by name), or the--revieweryou name. The job runs as a background review-only job onompwith a fresh per-job session,--no-fix-targetsemantics, and the first model of the purpose's pool; merge-gate independence rules apply unchanged. Selection is PURPOSE-SCOPED: an agent holding a verdict of a different purpose at this head is still eligible, and the refusal fires only when every candidate already answered THIS purpose. - Subscribes the requester to the exact-head verdict FOR THAT PURPOSE. The
subscription key is
owner/repo#N@sha|purpose, so acodeverdict cannot terminally satisfy asecurityrequest at the same head — enforced on BOTH doors: the producer resolves the purposed and bare keys in one transaction, and the subscribe-time recheck matches purpose as well as head, so a waiter registering after a different-purpose verdict is not satisfied by it.gitmoot org await reviewkeeps the bareowner/repo#N@shakey and its any-purpose meaning, and a routed review inherits its purpose into REVIEW delegation children so a staged review still answers the purposed wait — scoped to review legs, because a non-review leg carrying a review purpose is read by consumers keyed on review type. The wake fires from the reviewing job's own state transition when the verdict is persisted, before and independently of gate advancement, and it carries the decision, findings count, executed-check count, evidence declaration and thegitmoot job show <id>command. A second request by the same role keeps its original wait; two concurrent requests for the same role and subject both ATTACH to the single live wait rather than one failing. When the wait's--ttlelapses it ends like every awaited fact: superseded when the PR has moved past that head, otherwise expired to the waiting role itself.
The request prints every hold it can see rather than leaving a requester to
infer one: the daemon not running, the disk guard pausing dispatch, and any
head-blind review that cannot satisfy an exact-head wait at all. review status
lists each review job with head, model, verdict, evidence and the daemon's hold
reason, and states the GATE's own capability in words — whether native
auto-merge is enabled, or disabled by the operator kill switch, in which case
the gate publishes status and merges nothing. An absent or not-applied gate
marker is never an approval.
Model pools are configured per purpose; an unconfigured purpose uses code:
[review_router]
code = ["devin/swe-2", "openai-codex/gpt-5.6-sol"]
security = ["anthropic/claude-opus-4-6", "devin/swe-2"]
Every entry is a provider-qualified omp model. When a delivery fails on a
PROVIDER quota or auth error before any verdict, the daemon advances the job to
the next pool entry and re-queues it immediately (event
review_model_fallback) instead of waiting out the provider's window; with the
pool exhausted the ordinary timed operational hold applies, and the shared
attempt budget bounds the whole sequence. A verdict, a finding, or a product
failure never changes the model: fallback exists for operational failure only.
A remote review falls back only to models whose provider omp can authenticate
inside the sandbox (anthropic, openai-codex, xai-oauth, devin); it
skips any other entry with event review_model_remote_skipped, and the pool
keeps that entry for local reviews.
Agent Templates
Agent templates are read-only installed data (#2204). Gitmoot inspects them
and reads an installed row's content into a job payload so it reaches the
agent's prompt. It cannot author, update, or distribute one: there is no
draft, validate, add, export, publish, pull, remote, diff,
revert, or update verb, and no template-remote config section. A
template that must change is edited or re-seeded directly in its
agent_templates store row. That is the accepted trade, not an oversight.
The two surviving verbs are read-only inspection:
gitmoot agent template list [--capability <cap>] [--runtime <runtime>] [--tag <tag>] [--output <output>]
gitmoot agent template show <template-id>
list prints every built-in definition and every installed row: available
means a built-in is registered but has no store row, installed@<commit> means
a row exists. show prints the definition and its metadata and, when installed,
the current version, content hash, source commit, promotion state, and content.
Discover templates by metadata:
gitmoot agent template list --runtime codex --output plan
gitmoot agent template list --tag review --capability ask
gitmoot agent template show frontend-reviewer
Start an agent against an installed template — the built-in thermo review template, say:
gitmoot agent start thermo-review \
--runtime codex \
--repo owner/repo \
--template thermo-nuclear-code-quality-review \
--start-daemon
Or the built-in full planner template:
gitmoot agent start project-planner \
--runtime codex \
--repo owner/repo \
--path . \
--template planner \
--start-daemon
Templates are versioned in the store. An agent uses the current version by
default, or a pinned version when configured with a reference such as
--template frontend-reviewer@v1. Queued jobs keep the exact template content
snapshot they were created with. The dashboard's Agents page shows a template's
version history.
The built-in coordinator recipes (review-panel, verifier) are coordinator prompts for the Orchestra pattern, selected per
invocation with --recipe <id> rather than started as long-lived agents:
gitmoot agent template show review-panel
gitmoot orchestrate project-planner "Review PR #123 in this repo." --repo owner/repo --recipe review-panel
For fast current-chat planning, use the Gitmoot skill with the same packaged
agent-templates/planner.md instructions instead of starting a background job:
Use the Gitmoot planner here. Write the implementation plan.
The current chat can also import any cached custom agent or template prompt:
gitmoot agent prompt frontend-reviewer
gitmoot agent prompt frontend-reviewer --json
This prints the prompt content for the current chat to apply locally. It does not create a job, start a daemon, resume a runtime session, or post a PR comment — a free read-only peek.
To track the here-method work by default, add --record: it opens a session
job on import (see "Session jobs" below) and returns the prompt with a header
line naming the job id, so the imported work shows in job list / the dashboard
once you clock out:
gitmoot agent prompt frontend-reviewer --record [--repo owner/repo] [--type ask|review|implement] [--json]
# prints: [gitmoot session job <id> — when this work is complete, run:
# gitmoot job close <id> --decision <approved|changes_requested|implemented|blocked|failed|skipped> --summary "..."]
# followed by the prompt body.
--record accepts either a registered agent or a bare template id:
- Registered agent: the repo comes from
--repo, else the agent'srepo_scope(error if neither is set); the session job records the agent name. - Bare template (no agent of that name registered, e.g. the packaged
planner):--repo owner/repois required — a template has norepo_scopeto fall back on — and the session job records the template id as its agent identity (#673). The repo must be tracked (gitmoot repo add owner/repofirst).
--type defaults to implement. When the imported work is done, close the job
with gitmoot job close <id> --decision …. --json includes the opened
job_id. Without --record, behavior is unchanged (no job).
Organization Registry
Organization mode is opt-in. Initialize a starter registry and verify the
required Herdr provider (>=0.7.5) with:
gitmoot org init
gitmoot org brief [--role owner] [--json]
gitmoot org chart [--json]
gitmoot org status [--json]
The registry uses [org] enforce = "warn"|"block" and
[org.roles."name"] entries with parent, scope, merge_rule, an optional
cosmetic display_name, an optional model runtime pin, an optional per-role
recycle_after duration override, and an optional pane Herdr binding. For
backward compatibility, a configured binding resolves as a literal pane id or a
unique exact live label. A literal id tracks one pane; a label tracks whichever
current pane uniquely carries that cosmetic value. org seat add validates the
registered local agent and stores agent:NAME. Roles without a binding report
unknown live presence, and
event wakes for them are skipped with an observable log and increment the
role's missed-wake counter rather than being inferred from a pane label. There
is exactly one root named owner; accepted scopes are *, owner/*, and
owner/repo. Malformed org configuration fails closed and loudly. brief
defaults --role to GITMOOT_ORG_ROLE, with an explicit flag taking precedence,
records passive last-seen presence for its role and can render static context
with provider state unknown during an outage; chart and status require a
live compatible Herdr snapshot. When configured,
brief --json and status --json include the role's pane binding. chart
and status append a ⚠ flagged (N missed wakes) marker after the role reaches the positive
[orchestrate].max_consecutive_missed_wakes threshold; their JSON rows expose
missed_wakes, flagged, and flag_reason. The default threshold is 0
(disabled). A missed-wake row more than 24 hours old is omitted from this flag
calculation; its stored consecutive counter remains unchanged for the next real
delivery attempt. status --json also exposes active_jobs, the live
queued-plus-running job count attributed to the role through ActingOrgRole
(#1057); it is distinct from daily or historical job counts. Escalations are
recorded with gitmoot message escalate and resolved with
gitmoot message resolve, using an answer or a linked journal note.
brief also includes peer_messaging guidance in text and JSON: use Gitmoot
messages for peer conversation, check the inbox on startup/resume, retain
message IDs in handoffs, and distinguish queueing from reading or completion.
For Claude-runtime jobs attributed with ActingOrgRole, an explicit provider
weekly-quota rejection marks that role unavailable until the provider's
stated reset time. org status prints ⚠ UNAVAILABLE, reason=quota, and the
UTC reset instant in the role detail; org chart appends the same warning, and
their JSON rows expose provider_state: "unavailable",
unavailable_reason, and unavailable_until. Enforcement is scoped to the runtime that hit the
wall (#1641): new operator dispatches to the role are refused, and already-queued
jobs for it stay held, only when the job's selected runtime is the walled one, so
a Claude wall never blocks the role's Codex or Kimi work. A per-job --runtime
override decides this, in both directions. An incident with no recorded runtime
(written before per-runtime attribution) still holds the whole role, and an
unrecognized recorded or selected runtime refuses rather than dispatches. The incident sends
one best-effort direct wake to the role's configured parent, then clears at the
reset instant or on that role's first subsequent successful Claude-runtime job,
whichever happens first. Success on another runtime cannot clear the Claude
wall. If Claude supplies no parseable reset, Gitmoot uses the existing bounded
15-minute quota fallback. Codex and Kimi quota-message detection are not part of
this phase.
Archived seats (herdrup#173, #1635): when Herdr archives an agent
(herdr agent archive), the daemon's org lane observes the archived block on
herdr agent list each tick and drops that seat from org chart, org status,
health counts, sweeps, nudge ladders, and live presence — at one shared roster
choke-point, not per consumer. Herdr owns archive state; Gitmoot only reads it,
and a missing block means ACTIVE, so either side can deploy first. The seat's
open directives are parked (nudge ladders suspended — not marked done, which
would assert a deliverable exists, and not cancelled, which would discard the
obligation) and return to the live sweep with fresh TTL anchors when an
unarchive is observed. If Herdr is unreachable the last observation holds —
exclusions are preserved rather than expiring toward inclusion — and
gitmoot doctor warns once archived seats are held on a stale observation
(default 15 minutes).
The read-only Org page consumes GET /api/org for the store-backed role tree,
health strip, typed escalations, and current signal feed, plus
GET /api/org/role/{name} for one role's identity, presence, recycle history,
and today's job counts. These endpoints open SQLite read-only and never contact
Herdr. Responses are cached for at most 15 seconds; data_as_of is the newest
persisted source timestamp, not the request time. detection_enabled is true
only when blocked_role_wake_after is positive and at least one org event rule
is enabled; otherwise detection_hint explains why an empty signal feed is not
evidence that every role is healthy. The enabled blocked-role evaluator also
persists its latest Herdr snapshot for these endpoints. Only observations from
the last five minutes are rendered as blocked, working, or idle; stale,
missing, done, and unknown observations render as never-seen. An active
provider quota incident renders separately as unavailable, including its
reason and reset boundary in presence_detail; it is never collapsed into
never-seen.
Session lifecycle (phase 3): [org] recycle_after = "24h" (a duration, per-role
overridable) marks a role recycle-overdue after it has been idle that long,
shown read-only in the recycle column of org status
(off | fresh | eligible | overdue). [org] recycle_enforce = "off" | "warn" | "block" (default off) then refuses (block) or advises (warn) new
--org-role dispatches from a role past its recycle_after, until it hands off
and recycles; journaling a handoff note is never blocked. recycle_enforce
needs a configured recycle_after to take effect. Both are binary-first —
deploy the binary before any config sets them. When recycle_enforce is not
off, an overdue refusal or warning also emits a repeating (once per
recycle_after) org.recycle_overdue event through the org event sink; route
it to a wake with org events rule add --on recycle-overdue --wake <role>.
Notification delivery is best-effort — reliable from a foreground agent ask,
but a short-lived --background/orchestrate dispatch may exit before the wake
fires.
Agent ask, run, review, and orchestrate accept
--org-role <name>. The role is validated and touched before dispatch, then
stored as acting_org_role in the job payload for provenance, and its scope is
enforced at enqueue. Capability booleans are not part of this phase.
External-coordinator workflow groups
Pass --workflow <label> to agent ask, agent run, agent review,
orchestrate, or job open. Labels are global lowercase slugs up
to 64 characters. One / may separate a namespace and campaign; both sides use
lowercase letters, digits, and single hyphens without leading/trailing hyphens.
Orchestration children and continuations inherit the label.
Require workflow labels
require_workflow defaults to true. In the default auto mode, an unlabeled
fresh agent dispatch is bucketed as adhoc/<agent>-<yyyy-mm-dd> and receives a
workflow_autolabeled event; it is never rejected. Set [workflow] require_workflow = false to opt a repository out. To reject unlabeled
dispatches, ensure the applicable global or repository policy explicitly sets
require_workflow = true, then set require_workflow_mode = "strict" and pass
--workflow <namespace>/<campaign>. Mode-only legacy configurations remain in
auto. Both settings can be overridden in [repos."owner/repo"]. GitHub
comment dispatches always take the auto-label
path in either mode so acknowledgement ordering stays unchanged; engine PR
reactions inherit their initiating dispatch's label instead. gitmoot doctor
always reports unlabeled-job drift as advisory diagnostics (including
session-open and task-recover rows that bypass enforcement), while the overview
shows that item only for repositories where the policy is enabled. gitmoot repo add --agents-md scaffolds the team discipline into AGENTS.md.
With require_workflow = false, dispatch and enqueue remain byte-identical;
doctor drift diagnostics remain always-on advisory, and the overview item
remains policy-gated.
Organization registry and scoped dispatch
The optional [org] registry is enabled by any [org.roles."name"] section.
Roles have parent, scope, merge_rule (owner, self, or none), an
optional model runtime pin, an optional per-role recycle_after
duration override, and an optional Herdr pane used by live presence and
event-rule wakes; exactly one parent-less role is required.
Scope entries are *, owner/*, or exact owner/name, and child scope must be
a subset of its parent.
Use gitmoot org validate to validate the registry against the live Herdr
snapshot and event-rule store. It fails when a role has no live pane, has no
enabled wake route, or a labeled live pane is not claimed by any role; each
failure includes category counts and a reason. Use gitmoot org show to view
the configured roles. When enabled, fresh local agent ask, agent run, agent review, and orchestrate dispatches require
--org-role <role> (or GITMOOT_ORG_ROLE) and reject out-of-scope repositories
at enqueue.
enforce = "block" is the default; "warn" allows the job and records an
org_scope_violation event. Merge rules are enforced for explicit
/gitmoot merge commands and advisory on other paths.
gitmoot org seat add <name> [--pane ID_OR_LABEL] [--parent ROLE] [--scope REPO,...] [--merge-rule owner|self|none] [--home DIR] creates or
repairs a role and installs addressed reply, review-verdict, blocked,
directive, escalation, and fact routes with stable IDs
org-seat-<name>-<kind>.
With --pane, Gitmoot resolves a literal live pane id first, then a unique
exact live label, validates its unique named local Herdr agent, and stores
agent:NAME. The binding follows that registered identity across pane replacement;
cosmetic label changes do not retarget it. Register the agent before adopting
its pane. Duplicate labels or agent names, unregistered/remote/archived agents,
unknown references, and an empty --pane fail before role or route writes.
Omit --pane to create an unbound role and the same routes; re-run the command
with --pane ID_OR_LABEL to bind that role later. If a configured binding stops
resolving, for example after Herdr recreates a pane with a new id, the same
command can rebind the role to a live unclaimed pane while preserving its policy
and routes. A configured binding that still resolves is immutable. An ambiguous
label binding must be disambiguated in Herdr before rebinding. Every successful
add validates the affected role and six routes. Bound creation and repair also
validate the live binding without making unrelated intentionally unbound roles
decide the exit code. Unbound creation reports the deferred bind command and an
ok role NAME unbound enabled_routes=6 verdict. The global org validate command
continues to report every unbound role until it is attached.
For a new non-owner seat, the acting role comes from GITMOOT_ORG_ROLE and
falls back to owner only when the variable is unset. The new seat inherits
that role as its parent and copies its scope. --parent must name an existing
role and cannot create a cycle. An explicit --scope must stay within both the
acting role's scope and the selected parent's scope. Merge authority defaults
to empty; an explicit --merge-rule cannot exceed the acting role's authority.
The empty-registry owner bootstrap keeps its * scope and owner merge rule.
The three policy flags initialize new seats only. An existing role accepts an
explicit policy value only when it matches the stored value; --scope matches
as an unordered set, and a changed value is rejected instead of being silently
ignored. An invalid --merge-rule is always rejected as invalid, on new and
existing roles alike. Re-running with matching values or without those flags
repairs missing owned pieces, fills an empty pane binding, canonicalizes a
matching label binding, or replaces a non-empty binding only when its former
target no longer resolves. It does not rewrite existing policy or duplicate
routes.
gitmoot org seat rm <name> [--force] [--home DIR] resolves the role's live pane
when it has a binding and checks every distinct Git checkout reported by that
pane's cwd and foreground_cwd. It refuses a dirty checkout or a branch whose
HEAD is not merged into the locally known origin/HEAD (falling back to
origin/main); unreadable branch state also fails closed. A safe removal deletes
the role and all of its wake routes, closes a resolved live pane, and validates
that the role, routes, and closed pane are absent. A role with no configured
binding has no pane to inspect or close, so removal deletes only that role and
its routes. A configured binding that is stale, absent, or ambiguous fails
closed before mutation and reports the org seat add rebind command. A provider
error for a configured binding also fails closed. Roles that still parent
another role cannot be removed.
--force retires a seat whose configured pane will never resolve again, which
is otherwise unreachable: rebinding is impossible when the pane is gone, so the
role and its wake routes cannot be deleted at all. Per #1175 an unbound role is
a defect with two remedies, bind it or remove it, and --force is the second
one. It applies ONLY to an unresolved binding, and the removal prints
pane check skipped under --force with the reason the binding did not resolve.
A stale id may still name a LIVE pane holding work that the check cannot
inspect, so forcing a stale binding asserts that pane is gone. An ambiguous
label warning names every matching live pane ID; removal does not guess which
one belongs to the role and therefore closes none of them. Inspect or close
those panes separately before forcing the removal. --force does not weaken
anything else: a pane that DOES resolve is still refused when its branch check
fails, and a provider error still fails closed rather than being forced through.
The six provisioned routes are enabled, addressed, and have an empty match
filter. Remove one by its stable ID with org events rule rm to quiet that kind;
this is destructive and re-running seat add recreates it. There is currently
no non-destructive event-rule disable verb.
After upgrading an existing installation, re-run org seat add <role> for each
seat to provision default routes introduced by the new release, including
review-verdict. The repair preserves the seat's existing policy and routes.
gitmoot org recycle <role> --kind <kind> --handoff "<note>" [--pane <id>] [--json] [--home <dir>] journals a typed handoff in the role-lifecycle workflow
org/<role>, builds the successor's boot prompt from org brief plus that
handoff, and starts the requested Herdr agent kind in --pane or the role's
configured pane. A pane binding and non-empty handoff are required. For safety,
recycle does not kill or send exit keys to the old agent: the pane must already
be at its interactive shell prompt. The Herdr start wait is bounded to 30
seconds; a failed start leaves the durable handoff note available for recovery.
When a role configures model, recycle passes --model <value> to the successor
only for the verified Herdr kinds codex, claude, and kimi; other accepted
--kind values silently ignore the pin without an error or warning.
gitmoot message escalate [--to ROLE] [--workflow LABEL] [--role ROLE] "QUESTION" records a tracked decision request in the shared inbox. The default
recipient is the sender's coordinator. An explicit ancestor or descendant is
allowed; the same role and peers are refused. Ordinary cross-tree conversation
uses message send instead and grants no assignment authority.
The escalation and its notification obligation commit atomically. No workflow
is required, and association does not reopen closed work. --json returns the
canonical message, thread ID, lifecycle and notification status.
Ordinary fleet conversation uses gitmoot message:
gitmoot message send deimos "The review is ready."
gitmoot message inbox
gitmoot message show 123
gitmoot message reply 123 "Checking it now."
Flags follow positional arguments. Use --workflow LABEL to associate an
existing workflow, or omit it: no workflow, job, subscription or acknowledgment
is required. All commands accept --json, --home DIR, and an operator
--role ROLE attribution override. Otherwise the sender comes from
GITMOOT_ORG_ROLE or an unambiguous current registered HERDR_PANE_ID.
Role attribution is not authentication on the shared host.
-h, -help or --help anywhere in a gitmoot message command prints that
command's usage and saves nothing. Message text, questions, answers and
directive bodies that are a single flag-like word, such as --help or -x, are
refused so a misplaced flag is never delivered as mail. Text that starts with a
dash but has more words, such as "- see PR 12", or a dash before a number,
such as -1, is sent normally. message directive send accepts -- before a
body to send flag-like text on purpose.
The same rules apply to every other command that saves free text: workflow note|describe|register|close --reason, task successor --reason, escalation repair --reason, job open|record --title, job close|record --summary,
agent heartbeat add --prompt, the message of agent ask|run|review and
orchestrate, org events rule add --match, org recycle --handoff and
org wake retry|supersede --reason. A help flag anywhere in those commands, and
in setup, agent start, agent subscribe and review request, prints usage
on stdout instead of saving anything.
Any configured role may talk to another; conversation grants no authority to
assign work or change permissions. Replies stay between thread participants.
The message and its notification obligation commit atomically. Inbox reads work
without Herdr and do not imply acknowledgment. inbox --before ID --limit 20
pages received mail newest first; show prints the full message. JSON retains
stored text; plain output scrubs terminal escape/control sequences.
notification_status distinguishes queued, submitting, submitted, uncertain,
failed, stalled, and resolved; none means read or completed. Uncertain pane
input is not blindly resent. This replaces the old org message, org escalate
and org directive sending surfaces, without aliases. Historical addressed
notes retain their IDs and source links. Import creates inbox projections,
never new wake obligations; unknown delivery stays unknown.
Batched messages follow the survivor's actual notification status. Coalescing
does not mean success: uncertain survivors and invalid historical links remain
uncertain. Status projection does not resend mail.
Replies reserve SQLite's writer before reading the parent, allowing brief
contention to clear without retrying the insertion. If an insertion's outcome
is unknown, inspect saved history before considering a manual retry.
Claude Code and Codex seats receive mail at turn boundaries through the
Gitmoot plugin's hooks, which run
gitmoot message pending --claim --hook UserPromptSubmit|PostToolUse|Stop --runtime claude|codex. The command marks the acting role's queued
notifications submitted with a turn-hook:<runtime>:<event> receipt, then
prints hook JSON naming each message once (ID, kind, sender, scrubbed preview,
gitmoot message show ID), at most 10 per hook. No mail prints nothing; it
always exits 0 and reports problems on stderr. See
Codex and Claude plugins for event support.
gitmoot message resolve ID [--answer TEXT | --note ID] [--role ROLE]
records an answer and resolution in the original thread and queues one notice
to the original requester. --note accepts a message in this thread or a plain
journal note in the associated nonempty workflow, never another private thread.
Only the requester or addressed coordinator can resolve it. Identity uses the
same explicit/session selection as other message commands. Identical retries
return the existing receipt; a different resolver or answer fails with a conflict.
Reading or conversational replies never resolve an escalation. Historical
requests with no identifiable sender have system provenance, not an invented
recipient.
gitmoot message directive send --to ROLE [--workflow LABEL] [--role ROLE] (--stdin | -F FILE | TEXT) records an authorized assignment in the shared inbox.
Put send flags before the body. The issuer must be an ancestor of the target;
peer, upward and same-role directives fail closed. A workflow is optional.
The directive and its pending notification commit atomically. Missing panes,
unsafe input states and uncertain submissions retain durable mail; a directive
does not grant permission to interrupt a busy seat.
gitmoot message directive ack ID [--role ROLE] is restricted to the target or
one of its ancestors and records acceptance, not completion.
gitmoot message directive cancel ID [--role ROLE] is restricted to the issuer.
Both require explicit or session-derived identity; missing identity fails closed.
The receipt remains linked to the directive's thread and notifies its issuer.
Receipt is normally recorded by the transport, not by the seat. When Herdr
confirms that a directive prompt landed in the addressed role's pane, Gitmoot
appends an [org:directive-delivered id=<id> to=<role>] marker itself, and
that marker satisfies the receipt obligation everywhere an [org:directive-ack ]
marker does: the acknowledgment ladder stops and the completion phase starts
from the delivery time. The delivered marker is a separate verb from ack and
carries to= rather than by=, because an acknowledgment is the seat's own
assertion and a machine must not write one on its behalf. Nothing in the
delivered marker claims the seat read the directive.
gitmoot message directive ack therefore remains available but is no longer part
of the delivery path, and the first-delivery prompt no longer asks for it. An
acknowledgment answers "did this reach you", which the delivery confirmation
already answers, and routing that question through a model turn spent the turn
boundary. The receipt write is best-effort: if it fails, the wake still counts
as delivered and the directive simply takes another acknowledgment-phase nudge,
which is the behaviour that predates this change.
A remaining acknowledgment-phase nudge now means "delivery is not proven".
A stalled, failed or delivery_unknown wake records no receipt, so the ladder
keeps running for exactly the directives whose arrival nobody can demonstrate.
The first-delivery prompt carries the directive itself, not a pointer to
its row: gitmoot directive <id> for <role>: <directive text> -- record completion with: gitmoot message directive done <id> --role <role>. Fetching a row
costs another turn, which is the whole cost this transport work is removing.
The carried text is the directive body with its [org:directive to=… from=… wf=…] marker header stripped, since the prompt already states the addressee.
A body over 4,000 characters is cut at a word boundary and states exactly
how much was omitted plus the command that returns the rest, for example
[3126 of 7126 characters omitted; read the whole directive with: gitmoot workflow show-note 42 --json]. Directive bodies on a live fleet have a median
of about 2,800 characters, so most arrive whole, and none stops mid-clause
without saying so.
gitmoot message directive done <id> [--role <role>] [--home <dir>] records
COMPLETION and ends the obligation, including its TTL nudges. Completion
authority is the target subtree: the addressed role, or a role below it in the
chart — someone who plausibly did the work.
Ancestors cannot complete, and that exclusion is the point. send requires
the sender to be an ancestor of the target, so permitting ancestors would permit
the sender under another name: a role could issue a directive and then certify
its own work as done. Ancestors are not stranded — they hold cancel, and the
two verbs assert different things. Completion says the work happened;
cancellation says it is no longer needed.
So the three verbs carry three different disciplines: ack is the target or an
ancestor, cancel is the sender, and done is the target subtree.
A completed directive stops being outstanding even when no acknowledgment was ever recorded, since completion is strictly stronger than receipt.
The directive TTL checker nudges each phase on its own finite ladder. The
acknowledgment phase counts with directive_nudge_count and the completion
phase with directive_done_nudge_count, because the first counter is cumulative
and never resets at acknowledgment, so one counter cannot bound both phases.
Each phase caps at directive_max_nudges, emits one terminal escalation
naming which obligation went unmet, and then stamps directive_exhausted_at.
Exhaustion is terminal and leaves two records: directive_exhausted_at on the
directive row for the evaluator's own reads, and an [org:directive-exhausted ]
marker note in the directive's workflow journal. The marker is what makes the
terminal state discoverable — ack, cancel and done are all marker notes, and the
journal is what operator-facing readers consume, so a column-only terminal state
would have been invisible to exactly the person who needs it.
The stamp is completion-phase only. An acknowledgment ladder that exhausts does not terminate the directive: a late acknowledgment starts a fresh completion ladder, and the acknowledgment phase's own terminal condition is its counter reaching the cap. Both the evaluator and the atomic completion claim refuse an exhausted row, so a racing sweep cannot walk the terminal state back.
The sweep window is sized from the count of currently open obligations rather than a fixed oldest-N, so a backlog of long-lived directives can no longer starve newer ones out of the window. TTL nags are delivered through the durable wake outbox with coalescing, like blocked and escalation wakes, rather than one live prompt per due directive per sweep.
A completion nudge is not delivered to a seat that is working. The nudge
says "acknowledged but incomplete; finish the assigned deliverable", and an
inbound pane message ends the turn in flight, so nagging a working seat
terminates the attempt to finish. The sweep reads the persisted live-pane
observation: a working state observed within the last five minutes defers the
nudge without spending a ladder step, and the nudge fires on the first sweep
after the seat stops or is superseded by the completion receipt arriving. Any
other state, including the unknown a closed pane reports, and any staler
observation, deliver the nudge exactly as before.
The deferral is bounded. Past anchor + directive_done_ttl * (directive_max_nudges + 1),
the point at which the whole ladder could have run, Gitmoot escalates to the
sender's current parent instead of interrupting the seat, stamps
directive_exhausted_at and records the marker note. A seat that reports
working forever therefore cannot hold an unmet obligation in silence, and it
is still never interrupted. The acknowledgment phase is unaffected: a receipt
request is not a nag about unfinished work.
The daemon evaluates directive TTLs on the existing one-minute org supervision
lane. [org].directive_ack_ttl defaults to 10m,
[org].directive_done_ttl defaults to 0s (completion nudges off), and
[org].directive_max_nudges defaults to 3. An unacknowledged directive is
re-woken at most once per ack-TTL interval. When its persisted nudge count
reaches the maximum, Gitmoot emits an escalation addressed to the sender's
current parent in the org chart. Acknowledgment stops ack nudges; an acknowledged
but unfinished directive is evaluated only when its completion TTL is enabled.
An internal per-directive completion-TTL override, when present, takes
precedence over the global value. Typed completion or cancellation receipts
close the evaluator obligation. With no enabled org event rules, the lane is
config-inert: it performs no directive scan and emits nothing.
gitmoot org await review --repo <owner/repo> --pr <number> --head <sha> --ttl <duration> [--role <role>] [--home <dir>] registers a bounded durable interest
in a review verdict. --role defaults to GITMOOT_ORG_ROLE; the role must exist
in the organization chart, --ttl is mandatory, and the exact head SHA is part
of the canonical subject key. Registration inserts the interest and rechecks
already-committed review jobs in one transaction, so a verdict that lands while
registration is starting is not missed. A later terminal review-job commit
satisfies only the matching repository, PR, and head, then writes an addressed
fact:<role> wake obligation.
gitmoot org await list [--role <role>] [--state waiting|satisfied|expired|superseded] [--json] [--home <dir>] shows live and
terminal subscriptions. The existing one-minute org supervision lane ends
overdue waits. A review wait the pull request has moved past ends in the
queryable superseded state, with the reason in resolution_detail, and wakes
nobody. A wait has been moved past when the same role later awaited that PR at
another head, when the PR is merged, or when the local PR record, observed after
the wait began, shows it closed or at another head. The local PR record never
supersedes a post-merge wait. Any other overdue wait is retained as expired and
wakes the waiter itself. An archived waiter's expiry goes to its nearest live
ancestor instead; with none, it stays addressed to the waiter. If the waiter
role was removed from the chart, expiry still becomes terminal and the wake
retains the removed role as its exact address. Delivery failure stays observable
and the wait never becomes immortal. Fact wakes are delivery only: they require
a fact event rule but create no acknowledgment or completion ceremony.
gitmoot org interrupts [--window 24h|7d|0] [--json] [--home <dir>] reports
how often each seat is interrupted, which is the variable that best
predicted completed work in the transport campaign (#1983). Per seat, over the
window: wakes, wakes per day, median gap between wakes, the share of gaps under
five minutes, a breakdown by source (workflow_note, escalation, blocked,
awaited_fact), delivered versus unproven, wakes collapsed by coalescing,
pending wakes with no enabled route, and completion nags recorded against
the seat's directives. --window accepts a Go duration, a <n>d day count, or
0/all for every recorded wake; it defaults to 7d. --json emits the same
report, including per-kind routeless totals.
Two properties make it a regression check rather than a dashboard. A collapsed
row is counted as an interrupt that did not happen, never as a wake, so the
coalescing saving is readable directly. And a pending row whose kind and role
have no enabled rule is reported as NO ROUTE with its oldest timestamp, which
is how a fleet discovers obligations that are waiting on configuration rather
than on a tick.
A pending row whose kind and role have no enabled rule additionally records a
wake_unroutable job event once per row, naming role, kind, source and whether
the route was removed (a retired seat) or never configured (a gap a route would
close), so NO ROUTE here has a durable, queryable counterpart.
Event-rule wakes are separately opt-in:
gitmoot org events rule add --on attention --match owner/repo --wake maintainer
gitmoot org events rule add --on review-verdict --match owner/repo --wake maintainer
gitmoot org events rule add --on blocked --repo tendwire --wake maintainer
gitmoot org events rule add --on pane_input_pending --wake maintainer
gitmoot org events rule add --on reply --wake maintainer
gitmoot org events rule add --on directive --wake maintainer
gitmoot org events rule add --on fact --wake maintainer
gitmoot org events rule add --on reply --wake operator --scope observer
gitmoot org events rule list
gitmoot org events rule set-scope --home /alternate/home <rule-id> observer
gitmoot org events rule rm --home /alternate/home <rule-id>
--on accepts escalation, attention, guard, job-terminal,
review-verdict, blocked, recycle-overdue, pane_input_pending, reply,
directive, or fact. A successful review terminal whose decision is
approved or changes_requested matches both job-terminal and
review-verdict and addresses both the requesting org role and the resolved
implementing role. When a successful ownership lookup finds no implement job
or branch lock attribution, the review's persisted lead identifies a
persistent implementing seat; lookup errors remain fail-closed. If neither
role can be resolved, addressed rules fail closed while observer rules remain
eligible. pane_input_pending matches the
org.input_pending event emitted when Herdr continuously reports
input_pending: true for a role's pane longer than
[orchestrate].blocked_role_wake_after; it re-nudges at most once per that
interval while the dialog remains pending. The pending signal takes precedence
over the pane's last idle or working activity status.
reply, blocked, escalation, and fact wakes use the durable wake outbox.
reply matches workflow notes addressed to the same role as --wake. Reply
rows commit atomically with their source note.
Blocked and escalation rows are persisted synchronously by the event sink after
the source transition; an insert failure is logged but cannot roll back the
emitting job. A working recipient's group waits 120 seconds from its oldest
compatible queued item; an idle recipient has no batching delay. Becoming idle
releases held mail on the next five-second drain tick, and newer arrivals do not
restart the deadline. Review results use this same policy. The old
org.wake_coalesce_hold setting is removed. Offline, unknown and unsafe recipients
remain pending; draft/dialog/operator safeguards still apply. Manual inbox reads
remain immediate.
Each wake carries at most ten compatible items. Reply prompts include retrieval
commands; blocked and escalation events retain redacted details. Collapsed rows
record coalesced_into, and message status follows the survivor rather than
claiming success. Invalid historical links remain uncertain. Different event
kinds, directives and awaited facts retain separate grouping boundaries.
Claude/Codex tool-return hooks observe the working deadline; prompt-submit and
Stop hooks release mail at idle boundaries.
Owner Grams retain their separate five-minute hold and immediate review results.
Wakes addressed to the owner role go to the owner as one Herdr Gram per drain
(at least two minutes apart) and need no event rule; gitmoot org status
flags owner alerts that failed or whose delivery is unknown.
Rules default to --scope addressed: an addressed rule is eligible only when
the event names a target role and that role matches --wake.
--scope observer exempts a rule from the addressee gate, so observers receive
both directed events and events without a target; address-less events never
broadcast to addressed rules.
Durable-outbox claim authorization is scope-blind: among enabled,
filter-matching rules for the event's own kind, wake-role equality with the
addressed target is the only routing condition. An observer-scoped rule is
therefore delivered when its wake role equals the target; when it differs and
no other target-role rule authorizes the batch, the batch remains pending.
set-scope changes an existing rule between addressed and observer.
Upgrades preserve the existing global view by promoting non-reply rules with an
empty match filter to observer; reply rules remain addressed because reply
already carries a target role. gitmoot doctor warns when an event kind with a
production target-role writer has no enabled observer rule, including when the
rule set is empty.
Filtered non-reply rules remain addressed after upgrade and must be promoted
manually with set-scope when observer delivery is intended.
Every outbox row retains a queryable pending, attempted, delivered,
stalled, failed, or delivery_unknown state, so never-attempted is not
confused with success and outstanding rows contribute to daemon tick health.
Repository comparison for a slash-bearing owner/repo filter is
case-insensitive and exact. Job IDs always use case-insensitive substring
matching, including slash-bearing delegation IDs. Without a slash, repositories
also use substring matching; omit either flag to match every event of that kind.
Pass only one of --match and --repo. The wake role must exist and set
pane = "<herdr-pane>"; Gitmoot resolves that value as an exact pane label first
and otherwise treats it as a literal pane id.
Delivery goes to the recipient's Gitmoot OMP inbox add-on; Claude Code and
Codex seats stay pending with waiting for recipient's next turn. A runtime
deferral, or any failure before the request is written, keeps the rows
pending with the reason and spends no attempt. A lost receipt is
delivery_unknown and is never resent automatically. Other conclusive failures
end the rows and record a wake_delivery_failed job event on
wake-outbox:<id> naming the role and cause. attention, guard, job-terminal,
review-verdict, recycle-overdue, and pane_input_pending wakes remain
best-effort; zero rules leaves the feature off.
An aged row whose delivery the store can PROVE is recorded delivered with a
wake_delivered job event carrying policy=resolved_by_destination_evidence,
rather than delivery_unknown. The proof is a note in the directive's own
workflow acknowledging it ([org:directive-ack id=<id> ...]) or recording its
delivery ([org:directive-delivered id=<id> ...]), naming that row's directive;
a reply-class row has no equivalent marker and keeps the unknown outcome.
gitmoot orchestrate planner "Coordinate the dashboard wave." --repo owner/repo --workflow fable/dashboard-redesign
gitmoot job list --workflow fable/dashboard-redesign
gitmoot workflow list
gitmoot workflow show fable/dashboard-redesign --limit 100
gitmoot workflow describe fable/dashboard-redesign "Coordinate and ship the dashboard redesign."
gitmoot workflow note fable/dashboard-redesign "Implementation started." --author operator --status active
gitmoot workflow show-note 42
gitmoot workflow close fable/dashboard-redesign --reason "Shipped and verified."
List/show include state counts, notes, first/last activity, and best-effort token
totals. workflow show keeps the newest 100 entries by default and displays
that window chronologically; use --limit 0 for the complete timeline or a
larger --limit N for a wider window. If entries are omitted, text mode reports
the shown and total counts with that guidance on stderr, and JSON includes
"truncated": true. Notes store bodies and authors verbatim. The read-only web
dashboard also renders labels as Galaxy hubs and provides a Workflows index plus
mission log at /workflows/<label>. active means queued/running; recent
means no work is live but activity occurred within 30 minutes; failed/blocked
workflows with an unacknowledged failure and 30 minutes to 24 hours of silence
are stalled; everything else is settled. A done or settled status
immediately projects as settled regardless of note recency, unless queued/running
work makes it active. The optional --pane, --session, and
--workdir note flags persist the latest coordinator handoff. Inside Herdr,
omitted identity flags are filled from the current pane: its label, full agent
session UUID, and working directory. Explicit flags always win; --no-auto
disables detection. Missing Herdr state, command failures, timeouts, and invalid
output are ignored so the note still succeeds, and author is not inferred.
Only a full UUID is eligible for the dashboard resume command. If coordinator
author metadata is empty, the newest note author is used.
workflow note --repo <owner/repo> records the note's repo COLUMN and nothing
else - it opts the note into no other behaviour. Malformed input is refused with
exit 2 rather than stored as a column nothing matches. Prefer setting it on an
operating-mode or reconciliation note: the workload-mode gate reads those
through two bounded windows, one repo-scoped and one for the repo-less rows, so
a scoped note cannot be crowded out of the window by other repositories' notes.
Each workflow has a stable description and live status. Description is
auto-seeded from a referenced local issue title, else the first note sentence,
else the label campaign; override it with workflow describe. Legacy
workflow note --summary remains a description alias and mirrors the retained
summary field for older clients. workflow note --status is the manual status
control and accepts only active, blocked, ready_to_merge, done,
settled, or parked (plus an explicit empty value to unset it). Put free-text
detail in the note body. Legacy status strings remain readable but cannot be
written anew. Each metadata field is limited to 300 bytes.
workflow close <label> [--reason "..."] refuses queued/running work, appends
a typed [workflow:close] note, and sets status to done atomically. Repeating
close on done or settled is an idempotent success without another close
note. A later note without explicit --status writes a preceding
[auto:workflow:reopened] receipt and returns the workflow to active;
explicit status remains authoritative.
The daemon conservatively auto-settles a workflow only when it references at
least one PR, every referenced PR is locally known as merged or closed, no job
is queued or running, its status is not blocked/parked/done/settled
(deliberate human-set states are never auto-settled), and the latest human note
or job update has been quiet for [workflow].auto_settle_after (default 24h;
set "0" to disable). Daemon receipts do not extend the quiet period.
Auto-settle appends an [auto:workflow:settled] note, sets status to settled,
never deletes data, and any later note revives the workflow. Two edges are
reversible-by-note rather than auto-revived: a task paused at awaiting_human,
and a PR reopened after auto-settle — post a workflow note to revive it.
Linked PR transitions add structured [auto:pr:...] notes as author daemon
and advance status at open, checks-green/ready-to-merge, and merged or
closed-without-merging. The workflow/PR/transition key deduplicates poll replays,
and automatic updates never overwrite description.
Tasks
Inspect and retire task state. Gitmoot no longer STARTS a task: task run went
with implementer dispatch (#2203), and task recover / task resume-work went
with it because both existed only to restart or re-enter a dispatched implement
job. Review dispatch still mints the review-pr-<n>-<hash> task identity, and a
planned task is the plan a SEAT picks up in its own checkout, recording the
work against the task id, which is how 568 of the last 570 implement rows were
already produced:
gitmoot task list --repo owner/repo
gitmoot task list --repo owner/repo --state implementing --json
gitmoot task list --repo owner/repo --state stranded --json
gitmoot task events task-001 --json
gitmoot task successor review-pr-46-06cf76fc --reason "PR still open"
gitmoot job record --agent lead --repo owner/repo --type implement --task task-001 \
--decision implemented --summary "What changed and why."
task list, task events and task successor are the whole verb set.
A disposed task (stranded, superseded, dismissed) is never resurrected,
so review dispatch refuses it. When its work must continue, for example a
stranded review task whose pull request is still open and needs another
exact-head review, gitmoot task successor <id> creates
<id>-successor-<n> in planned. It copies the repository, goal and title,
and records successor_created on the disposed task and created_as_successor
on the new one. Dispatch then binds the review to the newest live task of that
chain, both by derived review-pr-* id and by branch. The successor takes over
the disposed task's branch (a branch belongs to one task), so workflow
advancement of the successor's review verdicts reaches the live task.
Repeating the command returns the existing live successor. A live task is
refused. The dispatch refusal names the exact command to run.
task events <id> lists the append-only trail, including daemon
task_dismissed_auto, opt-in
task_dismissed_planned_ttl, closed-unmerged pr_closed_unmerged, terminal
top-level implement triage (task_blocked_terminal_no_pr or
task_blocked_job_failed), and job-retry recovery.
Past [workflow].stale_task_ttl, blocked tasks follow an evidence ladder: own
merged PR (merged), later merged work on the same referenced issue
(superseded), closed subject (superseded), then terminal stranded. No tier
uses git ancestry. An open awaiting_human_merge PR remains protected and lands
in the visible stranded queue. JSON task rows expose disposal_tier,
disposal_reason, disposed_at, and disposal_escalation_role; an unroutable
terminal escalation is recorded without keeping the task alive.
The blocked-task alert has a separate finite ladder: three interval-spaced
nudges, one terminal escalation, then no further alerts while the task remains
queryable. Only the evidence-disposal pass transitions task state.
A seat that dies mid-work
Gitmoot no longer dispatches the implement job that this section used to
recover, so the dead-implementer case it finalized cannot occur through Gitmoot
any more (#2203). A seat that abandons its own work owns its own worktree:
finish or discard it with ordinary git, then record the outcome with gitmoot job record --type implement. There is no task recover.
A dismissed task is still not resurrected implicitly — ordinary allocation and
workflow advancement cannot bring it back. Retrying one of its jobs restores it
explicitly and records task_recovered_job_retry.
If an existing task worktree has fallen off the resolved base lineage, Gitmoot
re-cuts it only when it is clean. When it also has uncommitted changes, the
delegation leg that would have used it preserves the worktree, moves the task to
blocked, and records stale_worktree_dirty_blocked; manually salvage, commit,
stash, or clean the changes before retrying. This is a read-only delegation
concern now: an implement leg cannot be allocated at all.
The daemon reads a bounded oldest-first stale window and processes up to 20
qualifying implementing tasks per repo poll.
[workflow].stale_task_ttl = "168h" is the default and "0" disables the leg.
updated_at is a conservative activity proxy. Live jobs, same-repo open-PR
branches, branches still present on origin, and remote-check uncertainty all
prevent automatic dismissal; successful transitions record
task_dismissed_auto.
Delegation worktrees use the separate default-on
[workflow].delegation_worktree_ttl = "72h"; "0" disables that pass. Only
final job owners older than the TTL are force-reclaimed. Blocked, queued, and
running owners remain pinned and are reported by gitmoot doctor.
Candidate-local lookup, runner, and removal failures skip only that worktree;
later candidates continue. Repeated failures log three times per path before
suppression. Every failure also advances a durable cleanup obligation; retries
wait one minute and the third failure becomes terminal quarantined. Doctor and
/api/health expose the quarantine count. Inspect rows with gitmoot job cleanup list --state quarantined and explicitly reopen a repaired target with gitmoot job cleanup reopen <resource-id>. Candidate-query and store-wide lookup failures
remain fatal.
[workflow].planned_ttl = "720h" is a separate repository opt-in for old
never-started plans. It is disabled by default; unset, empty, "0", and invalid
values all mean off because automatic dismissal can destroy human planning
context that Gitmoot cannot reconstruct. When enabled it uses the
same live-job, same-repo open-PR, remote-branch, and remote-uncertainty skips and
records task_dismissed_planned_ttl. Task allocation claims
planned -> implementing atomically at the write boundary, so a concurrent TTL
dismissal cannot be raced by another allocation.
A clean closed-unmerged PR moves pr_open, reviewing, or
changes_requested to blocked with pr_closed_unmerged; ambiguous PR state
remains conservative. After advancement and delegation handling, a terminal
top-level implement job with no attached PR and no live successor checks whether
the task branch already has an open PR. An implemented success with that binding
returns the task to pr_open and records
task_terminal_pushed_to_open_pr; without an open branch PR it blocks with
task_blocked_terminal_no_pr, whose reason names the recoverable branch and
recorded head SHA when available. Other terminal outcomes remain
task_blocked_job_failed. Delegation children, retries,
continuations, or pending advancement are not reclassified.
PR Comments
Use GitHub PR comments as the public audit trail:
/gitmoot help
/gitmoot status
/gitmoot <agent> review [instructions]
/gitmoot ask <agent> [question]
/gitmoot retry <job-id>
/gitmoot cancel <job-id>
/gitmoot merge
/gitmoot resume <job-id> retry|continue|abort|answer [instructions]
@<agent> ask|review [instructions]
implement is NOT a PR-comment command. It parses as an unknown action and is
refused (#2203); on a pull request an unrecognized action is logged without a
reply, so nothing will answer it. Record implementation work with gitmoot job record --type implement instead.
/gitmoot merge always posts the outcome it observed. A refusal names the
cause and remedy, including an acting role whose merge_rule denies the
operation, a repository ruleset that requires GitHub's merge queue, no approval
for the current head, or an approval bound to an ancestor head. When
merge_gate.auto_merge = false, the reply also states that no earlier
gitmoot/merge-gate marker was expected: absence of that marker is not evidence
that the gate passed. The explicit command still runs the gate.
A bare @<agent> <action> … mention on a PR comment (or, with the daemon's
--watch-issues flag, an issue comment) is treated as the same command as the
/gitmoot <agent> <action> form (#389). /gitmoot resume <jobID> retry|continue|abort|answer resumes a delegation tree paused by
escalate_human or an ask-gate human_questions pause — see the
result contract for the pause/resume semantics. See also
the PR comment workflow.
Before parsing, Gitmoot removes triple-backtick and tilde fenced code blocks and closed inline backtick spans (including single-backtick spans).
Symptom: my command in a bulleted list did nothing. Any command line indented by four spaces or a tab is treated as code and will not run, including list-item continuation under a bullet or numbered item. For example, this command is ignored:
- Please run:
@helper ask check the build
Put the command at column zero to run it:
@helper ask check the build
GitHub must then
report that the comment author currently has write, maintain, or admin
repository permission; unauthorized addressed commands are rejected without
parsing their command text. Ordinary prose and code examples produce no reply.
An authorized malformed command still produces a visible routing error. An
unclosed fenced code block treats the remainder of the comment as code, so any
later command is ignored without a reply.
Jobs And Locks
gitmoot job list --repo owner/repo # add --json for machine-readable rows
gitmoot job list --killed # only deliveries a signal killed, which nothing requeues
gitmoot job show <job-id> # add --json for the full job + operational detail
gitmoot job watch <job-id>
gitmoot job watch <job-id> --transcript [--log-path <path>] [--runtime codex|claude|kimi|omp|shell]
gitmoot job transcript <job-id> --export md|jsonl [--output <path>] [--log-path <path>] [--runtime codex|claude|kimi|omp|shell]
gitmoot job transcript --all [--state succeeded,failed] [--since 720h] --export jsonl [--output <path>]
gitmoot job events <job-id>
gitmoot job run <job-id>
gitmoot job retry <job-id>
gitmoot job cleanup list [--state pending|retryable|removed|quarantined] [--json]
gitmoot job cleanup reopen <resource-id>
gitmoot job gates <job-id> # list resumable gates; add --json
gitmoot job gates clear <job-id> --need "<text>"|--all # satisfy gate(s); auto-resume on last
gitmoot job cancel <job-id> # one queued|running|blocked job
gitmoot job cancel --state blocked [--older-than 7d] [--repo owner/repo] [--agent name] [--yes]
gitmoot job kill <root-job-id>
gitmoot job answer <job-id> "<question-id>: answer text" [--json] # resume a job paused at awaiting_human
gitmoot lock list --repo owner/repo
gitmoot lock show owner/repo <branch>
A delivery killed by a signal records a delivery_signal_killed event naming
the signal and which evidence identified it. Both renderings count: a
direct-child runtime reports signal: terminated, while a wrapped CLI such as
claude or omp reports exit status 143 and the signal name is lost, so a
consumer matching only the first would miss most review deaths. A timeout is
excluded twice over, by its own deadline error and by its job_timeout event,
because a timeout burned its whole wall rather than being abandoned.
--killed lists exactly that population. Nothing requeues it, deliberately.
Work consumed before a restart is never silently re-run: a killed delivery may
already have pushed a branch, posted a pull-request comment, taken a lock or
spent tokens, so re-running it trades a lost job for a double-executed one.
Inspect these rows and retry explicitly with job retry.
Terminal background ask and review jobs that ran in a throwaway read-only
worktree preserve a bounded git status --short plus git diff HEAD snapshot
before Gitmoot removes that worktree. job show prints the captured diff and
job list adds a compact DIFF: badge. Their JSON forms expose
read_only_worktree_diff, read_only_worktree_diff_truncated, and
read_only_worktree_diff_error; the same durable fields are present under
job show --json's payload. Capture is capped at 4 MiB, and an oversized
snapshot ends with an explicit omitted-byte marker instead of being silently
cut. Git subprocesses and the wait for each index-file copy share a 10-second
context: on expiry Gitmoot kills the subprocess or stops waiting for the copy.
The operating system still owns cancellation of an in-progress filesystem
syscall, so this is a bounded-wait guarantee rather than a promise that kernel
I/O itself is cancelled. Failures are recorded and never prevent worktree
removal.
When standard output is an interactive terminal (and NO_COLOR is unset),
the transcript renders styled: agent turns get blank-line spacing and keep
their line breaks plus lightweight heading/list/inline-code treatment. Tool
calls use type-specific icons; shell output previews its last five lines while
read/search output previews its first 10-15 lines, with exact omitted-line
counts. Tool and turn durations render dim, cancelled tools render yellow,
completed machinery and usage render dim, and failed tool results render red.
Piped or redirected output always uses the plain byte-stable format.
Every transcript opens with an orientation header — job action, agent, runtime/model (per-job override first, then the agent default), workflow label, and the redacted, length-capped prompt — so a pane or saved transcript is self-describing.
job watch --transcript follows a runtime tee log from offset zero and renders
redacted, bounded human-readable runtime output until the job settles, then
drains the file to EOF. It is incompatible with --json. Without --log-path,
Gitmoot derives the job-mode path under <home>/logs/jobs/; if that file is not
available, it prints transcript unavailable; showing job events and uses the
normal event watcher. --log-path and --runtime are primarily internal wiring
flags, but remain usable for diagnosis. When --runtime is omitted, the job's
runtime override wins over the registered agent runtime.
Fidelity follows each runtime's actual output contract: Codex JSONL renders
live; Kimi stream-json is turn-buffered and kimi-code 0.19.2 reports no usage;
Claude emits only its final JSON envelope, so its transcript remains quiet until
completion; shell output passes through as redacted raw lines. omp's NDJSON is
retained verbatim and currently passes through undecoded — every row carries
the transcript kind raw, and omp's own type discriminator survives only
inside each row's raw JSON text, so an export offers no per-event kinds; a
translator that lifts tool calls and usage out of the stream is a named
follow-up. Usage is labeled
latest reported usage because resumed Codex counts can be session-cumulative.
Malformed or unknown lines degrade individually to redacted capped raw output
without stopping later lines.
Where a review's wall time went (phase_profile)
Every review job with a retained transcript emits one phase_profile job
event per attempt, visible in gitmoot job events <job-id>. Other job types
are deliberately untouched: the profile is appended after a job's terminal
events, so emitting it everywhere would change the observable event sequence of
jobs this measurement has no business affecting.
The transcript records what ran, not when — the runtime streams carry no event timestamps, and snapshot replay deliberately refuses to invent elapsed time from parser speed — so the timing is recorded live or not at all.
Two identities, and the difference between them is the finding. Commands can run concurrently, so their durations do not partition anything on their own:
covered_ms + residual_ms == wall_ms
sum(bucket_ms) == covered_ms + overlap_ms
covered_ms— wall time with at least one command in flight (the union of command intervals).residual_ms— wall time with no command in flight; non-negative by construction because it iswall - covered, neverwall - sum.overlap_ms— concurrent command time, reported rather than absorbed.bucket_ms/bucket_count— per-command measured time classified astest,build,vcs,mixed,unknownorother. Classification reads every segment and consumes wrapper arguments, sotimeout 25m go test ./...istestandgit commit -m "go test is slow"isvcs.unknownis a refusal, not a leftover. The classifier is a cheap lexer with a declared grammar (quoting, escapes, sequencing operators and redirect forms, comments, wrapper prefixes); anything outside it — substitutions, backquotes, process substitution, subshells, arithmetic commands, heredocs,;;, an unterminated quote or a trailing line continuation — yieldsunknownrather than a confident bucket. Its time still counts incovered_ms.othermeans "understood, matched no phase";unknownmeans "not classifiable by this lexer".commands,in_flight(still running at close — their time is counted),unpaired(a result whose call id was never opened),tool_events(non-shell tool activity such asfile_change),id_collisions, anddropped_bytes(from a capped over-long unterminated line).attempt— the job's lifecycle generation, becausejob retryre-delivers the same job id.coverage— read this before the buckets.decomposedmeans the runtime emits per-tool events (Codex, Kimi).opaque_runtimemeans it does not: Claude reports one final envelope and no tool events, so an opaque row has zero commands however long the job took. That is a blind spot, not a measurement that the job spent its time outside commands.
The grammar is an ACCEPTOR with per-character provenance. A command is
classified only if every token positively matches the declared shapes:
assignments (whose NAME characters are unquoted and form an identifier),
redirections (whose OPERATOR characters are unquoted, with a validated operand),
a plain command word, and arguments carrying no UNQUOTED { } * ? [ ] ~ < > | &.
Quoting is tracked per character, because a quote around one fragment does not
disable expansion in the rest: go test ./internal/"cli"* still globs and
returns unknown, while go test "./internal/cli*" is literal and returns
test. A redirection and its operand are validated as ONE unit, so
go >probe* test and go > "" test refuse, while go 2>"out file" test and
go 2>&"1" test classify normally. Backslashes follow bash: inside double
quotes a backslash is literal unless it precedes $, a backquote, " or \,
and an unquoted backslash-newline is a line continuation. env consumes its own
assignments, so env PROBE=1 go test is test.
Wrapper handling follows each wrapper's real semantics: only env consumes
variable assignments, so env PROBE=1 go test ./... is a test while
bash PROBE=1 go test ./... is not - bash, sh, zsh and nohup take that word as
their script or command operand, and really do exit without invoking Go.
Shell INTERPRETERS are not executable wrappers. bash, sh and zsh unwrap
only when a command-string option is present (-c, -lc, -ce, and other
short bundles containing c; long options never qualify). Without one, the
operand is a SCRIPT FILE - bash go test ./... runs a script named go and
never invokes the Go toolchain, so it is not reported as a test. Executable
wrappers (nohup, env, timeout, sudo, ...) do exec their operand and
keep unwrapping.
Interpreter OPTION STATE is honoured, not guessed from token shape: -- ends
option parsing (so bash -- -c cmd runs a script named -c), value-taking
options consume their argument (--rcfile FILE, -O NAME, -o NAME), and an
option outside the declared set - an invalid bundle such as -zc, or an
ambiguous value letter in mid-cluster - is reported as unknown rather than
given a bucket, because the shell aborts or the parse is ambiguous. A -c is
honoured only when it belongs to an interpreter: nohup -c cmd execs a command
named -c.
The implemented boundary, stated exactly. After -c, exactly ONE operand is the
command string; following words are positional parameters, so
bash -c "go" test ./... is not a Go test. Options that parse or print instead
of executing - -n, -D, --help, --version - mean the command string never
runs. Option grammars are PER INTERPRETER: POSIX sh rejects --noprofile, -O
and -o here, boolean long options take no inline value (--noprofile=x
aborts), and zsh is UNMEASURED on this box, so only -c and -- are declared
for it and every other zsh option is reported unknown. Anything outside a
declared grammar is unknown, never a bucket.
Option POLARITY and per-interpreter VALIDITY are modelled as data, not shared
logic. -x sets and +x clears, and the last setting wins: bash -n +n -c cmd
runs the command while bash +n -n -c cmd does not. Tables are measured against
the interpreters installed on the machine - bash accepts -h, the installed
dash does not and has NO long options at all, and --pretty-print runs the
command rather than suppressing it. zsh is unmeasured here, so only -c and
-- are declared for it and even +c refuses, although +c does introduce a
command string in bash and dash.
Options are modelled PER OPTION on four axes, each measured against the
installed interpreters. EXECUTION EFFECT distinguishes clearable noexec (-n,
+n, -o noexec) from sticky terminal forms (-D, +D, --help,
--version, --dump-strings, --dump-po-strings) that print or dump and are
never restored by a later +n. ARGUMENT FORM: long options take a separate
value only, so --rcfile=/dev/null is refused while --rcfile /dev/null runs.
VALUE DOMAIN: -o/-O values are checked against the shell's own set -o and
shopt names, which differ per interpreter - sh -o pipefail is refused
because dash does not have it, and dash has no shopt at all. ORDERING: a named
long option after a short cluster is refused, matching the real shells, while
the bare -- terminator is exempt. Anything outside a measured table is
unknown.
WRAPPERS have declared grammars too, one per wrapper: options are matched by
exact token with value domains measured against the installed binaries - a
timeout duration is any number the shell's own parser accepts (including .5s,
1.s, +1s, 1e3 and inf) with at most one lowercase s/m/h/d suffix and no
leading minus; a signal is an exact name - the full kill -l set including the
RTMIN/RTMAX family and the IOT/CLD/POLL aliases - or 0-64, with no whitespace
padding; nice -n is a signed integer; ionice -c is 0-3 OR a class name
(none, realtime, best-effort, idle) and ionice -n has no small upper
bound; xargs -n is at least 1 and -P allows 0, both with an optional +;
and stdbuf's THREE STREAMS DO NOT SHARE A DOMAIN - -o and -e take L, -i
does not, and all three take 0 or a size with a unit suffix - and the -- terminator is accepted in option
position before the duration, so timeout --definitely-invalid 1s ..., timeout -s -999 1s ... and
timeout 1x ... are refused rather than stripped. The token after the duration
is the COMMAND even when it is dash-prefixed. THE CLAIM IS DELIBERATELY NARROW
in two further places. Where two installed implementations of a wrapper
disagree - GNU coreutils exits 127 on timeout <dur> -- cmd while another
timeout on PATH runs it - the result is unknown, because measured-twice-with-
different-answers admits no confident bucket. And where a shell option's effect
depends on runtime state rather than on the option, classification fails closed:
bash restricted mode reports unknown because what it blocks depends on the
command text - but only where it is actually ENABLED: -r, --restricted and
-o restricted refuse, and so does +r AFTER one of them, since bash exits 2
on that. A bare +r is valid, leaves the shell unrestricted and classifies
normally, and an errexit
command string (-e, -o errexit) reports unknown when it has more than one
segment, since which segment runs depends on an exit status no lexer can know.
A single-segment errexit command still classifies normally.
job transcript <job-id> --export md remains the deterministic, ANSI-free
Markdown snapshot. --export jsonl emits schema-versioned, self-contained
trajectory rows for every normalized event. Bulk export requires the explicit
--all guard; --state and --since filter the created-time-then-id ordered
stream. Bulk mode skips pre-retention or GC-missing logs and reports counts on
stderr, while explicit single-job absence is an error. --output <path> uses a
mode-0600 temporary file plus atomic rename. Oversized runtime lines become
marked truncated raw steps instead of aborting the export.
JSONL export redacts every text-bearing event field with Gitmoot's best-effort
credential masker and has no raw bypass. The source log remains unredacted and
mode 0600; best-effort export masking is not a vault.
Verified Codex command/file-change events and Kimi function tool calls/results
render as typed compact lines; unrecognized shapes keep the generic/raw
fail-open path. Render-time redaction is a per-line best-effort defense in depth:
a secret split across physical lines may be only partially masked, and the raw
tee log plus the external tail -F fallback remain unredacted.
Resumable gates (make blocked + needs actionable)
When a stage returns blocked with a needs list (e.g. needs: ["Maps API key"]),
gitmoot persists each need as a gate attached to the blocked job. gitmoot job gates <job-id> lists them (open / satisfied); clearing a gate marks the blocker
resolved:
gitmoot job gates clear <job-id> --need "Maps API key" # satisfy one need
gitmoot job gates clear <job-id> --all # satisfy every open gate
When the last gate is cleared, the blocked stage auto-re-runs via the same
RetryJob machinery gitmoot job retry uses (re-queued, then dispatched by the
daemon; downstream stages follow the normal delegation DAG) — resume happens on
clear, no polling. A session job (externally driven) and a stage whose tree is
paused awaiting a human (escalate_human / ask-gate) are never auto-resumed
even with all gates cleared — a resource gate must not bypass the human's gitmoot resume decision; the command reports not resumed: … with the reason. A blocked
job with no needs records no gates and is byte-identical to before.
Session jobs (record "here"-method work)
The "here" method — importing an agent's prompt into your calling session with
gitmoot agent prompt <agent> — does the real work in your session but creates
no gitmoot job, so the dashboard / job list / event stream never reflect it.
Session jobs record that work as a first-class tracked job without gitmoot
spawning a runtime — a clock-in / clock-out pair (plus a one-shot recorder):
# Clock in: create a RUNNING, externally-driven job (no dispatch); prints its id.
gitmoot job open --agent <name> --repo owner/repo --type ask|review|implement \
[--title "..."] [--task <id>] [--parent-job-id <id>] \
[--pr <n>] [--head-sha <sha>] \
[--workflow <label>] [--json]
# Clock out: apply the result and move the job to its terminal state.
gitmoot job close <id> --decision approved|changes_requested|blocked|implemented|failed|skipped \
[--severity P0|P1|P2|P3] [--summary "..."] \
[--pr <n>] [--head-sha <sha>] [--branch <name>] \
[--model <name>] [--input-tokens <n>] [--output-tokens <n>] [--json]
# One-shot post-hoc: create an already-terminal job (open + close in one).
gitmoot job record (--agent <name> | --acting-role <role>) --repo owner/repo --type ask|review|implement \
--decision <decision> [--severity P0|P1|P2|P3] \
[--title "..."] [--summary "..."] [--task <id>] \
[--parent-job-id <id>] [--pr <n>] [--head-sha <sha>] \
[--branch <name>] [--model <name>] \
[--input-tokens <n>] [--output-tokens <n>] [--json]
--agent and --acting-role are mutually exclusive and exactly one is required.
Use --agent when a registered agent performed the work. Use --acting-role when
it was done in session by an org role that is not a registered agent (#1718):
roles and agents are separate namespaces, so recording an agent for role work
would be a false statement about who acted. The role is validated against the
configured org registry, and only its existence is checked -- availability and
recycle enforcement are dispatch-time policies, while this command records work
that has already happened. Attribution is then the role, and the merge gate still
enforces independence against it: a reviewer may not approve work attributed to
the identically named role.
An externally_driven job is created directly in running (it never queues, so
the daemon never claims or Delivers it — no runtime subprocess, no runtime-session
or checkout lock) and the stuck-running reaper skips it, so a session may
hold it open for as long as the work takes. close reuses the exact result path an
engine-run job uses: --decision maps to the same terminal state
(approved/changes_requested/implemented/skipped -> succeeded, blocked -> blocked,
failed -> failed) and emits the same finished/failed/blocked event, so a recorded
job is indistinguishable from an engine-run one in the dashboard and events. A job
can be closed once (it must be a running session job); an orphaned open job
stays running (reaper-exempt) until you job close --decision failed or job cancel <id> it. A session job is never engine-executed, so job retry refuses
it (retrying would re-queue it for a real runtime with an empty payload) — recover
one by opening a fresh session job instead. The agent and repo must exist.
--parent-job-id must name an existing job. Model and token values are
caller-reported evidence: Gitmoot records only values supplied explicitly to
close or record, and leaves the model empty and token counts zero when they
are omitted.
A review recorded with changes_requested must pass --severity with the
highest finding severity. Other decisions and non-review session jobs may omit
it.
For a seat's internal fan-out, the recording convention is to use the seat's
registered agent name, link each recorded unit to the seat job with
--parent-job-id, and give it a descriptive --title. This is a convention,
not an enforced identity or spawn relationship; the CLI validates only that the
agent and parent job exist.
For an in-session PR review, clock in before reading the diff and bind the display row to its exact head and workflow journal:
gitmoot job open --agent <name> --repo owner/repo --type review \
--pr <n> --head-sha <sha> --workflow <label>
gitmoot workflow note <label> "reviewed tests and error paths"
# Post the verdict, then:
gitmoot job close <id> --decision approved|changes_requested|blocked \
--summary "..." [--severity P0|P1|P2|P3]
For a running externally-driven review, job list and job show derive
review_status: in_progress|stalled from the newest workflow note, falling back
to the job's creation time; the signal becomes stale after 20 minutes.
head_sha is also exposed in text and JSON. Every such session status is explicitly
review_status_grade: reported and
review_status_authority: non_authoritative (text lists use REVIEW (reported; non-authoritative): ...). Workflow notes are caller assertions, not
system-observed reviewer attribution. These fields are display-only: they never
satisfy, block, or otherwise feed the merge gate, and this feature never writes
tasks.state.
When an externally-driven review closes, its payload retains
review_status_grade: reported; job list --json and job show --json continue
to expose that field after the running liveness status ends. The grade is never
observed or verified because both the reviewed head and verdict came from
caller-supplied flags. This durable reported record is not merge-admissible
evidence, and no merge gate consumes it.
For a running engine-dispatched review with an isolated worktree, job list
also samples the verified daemon's descendant process tree twice. A descendant
whose cwd is that worktree (or a directory below it) yields
review_status: in_progress; a conclusive absence in both samples yields
review_status: stalled. A runtime root remains visible while the agent is idle
between tool commands, so the check does not depend on a transient tool child.
These statuses carry review_status_grade: observed and
review_status_authority: non_authoritative. If the daemon identity or process
table cannot be verified, the status is omitted rather than guessed. The
daemon-valued jobs.runner_pid, job timestamps, and event age do not decide this
status. This is observation only: it does not cancel, retry, reclaim, or feed the
merge gate.
Make in-chat / "here" work show on the dashboard. The one-step default is
gitmoot agent prompt <agent-or-template> --record: it opens the session job as
you import the prompt and prints a header naming the job id (see the agent prompt section above). It works for a registered agent (repo defaults to its
scope) and for a bare template id with an explicit --repo (the template id is
recorded as the identity, #673). Apply the prompt, do the work, then clock out with
gitmoot job close <id> --decision …. That is all it takes for otherwise-invisible
current-chat ("here") work to appear in job list, the dashboard, and the event
stream — no daemon, no runtime, no PR comment. Use plain agent prompt (no
--record) only when you just want to read a prompt without tracking it.
For a queued or blocked job, gitmoot job list appends a WHY: column and
gitmoot job show prints a why_stuck: (and, when a lease applies, a
next_retry_at:) line explaining what the job is waiting on — e.g. waiting on runtime session lock runtime:codex:<ref> (held by job <id>), blocked: awaiting human, auth failing: …, throttled: …, or retrying: … (#552). The reason
is derived from the most authoritative existing signal (the latest
reason-bearing job events entry plus the owning resource lock's lease); a
healthy job's output is unchanged.
A queued job whose reason is already recorded no longer renders as a bare
queued row (#1887), and which form you see depends on whether a
reason-bearing event exists, because the event is the more authoritative
signal:
- When the classifier deferred the job it wrote a
blocker_deferredevent, and that event is rendered:WHY: blocked-operational: <class>: attempt <n>/<max>. This is the common case for a real runtime deferral. - When the hold exists only in the payload — no reason-bearing event available —
the payload is read directly and rendered
WHY: deferred (<class>), with the recorded earliest retry as(next retry <RFC3339>)and, when the deferral usually needs a human,[action: …].
A row whose blocker_retry_at is absent renders deferred (<class>), retry time unknown and leaves next_retry_at empty; it never shows a zero time.
Absence is the common case on at least one path, and a formatted empty timestamp
would read as a retry long overdue.
gitmoot job show prints the same reason as why_stuck: (and next_retry_at:,
suggested_action:) rather than the WHY: column form used by job list.
The sibling cause is surfaced on the same pass (#1553), but it is never
inferred from a lock table: a job withheld while another job works the repo is
rendered from the deferral the daemon actually recorded for it. The pre-flight
emits branch <branch> is locked by <owner>, which is classified as the
checkout_contention blocker class and persisted as both the payload class and a
blocker_deferred event naming the branch and the holder, so it renders through
the two forms above like any other deferral.
Neither resource_locks nor branch_locks is consulted, and that is a
deliberate reversal of two earlier attempts. Resource-lock keys are
runtime:<rt>:<ref> and checkout-mutation:<absolute path> and encode no
repository segment, so matching them against a repo name attributed unrelated
holders. Reading branch_locks instead was the wrong inference rather than the
wrong table: a branch lock records who owns a LANE, not who is withholding a
given job, and the lock is acquired with the agent as owner BEFORE that agent's
own job is enqueued — so an ordinary queued job was reported as withheld by its
own agent. A queued row with no recorded deferral is therefore left silent
rather than given a holder no lock row can prove.
gitmoot job watch surfaces a hold while it waits, as HOLD: <reason> with the
optional (next retry <RFC3339>) and [action: …] suffixes, re-checked on every
poll so a hold that begins after the watch attached is still shown. The line is
reprinted only when the hold changes. In the default event mode the watcher
already replays every job event, so HOLD: is emitted only when no
reason-bearing event exists — otherwise the deferral would be stated twice under
two labels. job watch --transcript has TWO paths and they differ, so the guarantee is
qualified: with a retained log it enters transcript.Follow and renders log
lines rather than job events, and the hold is printed there in every case
because no event can have spoken for it. With NO retained log it prints
transcript unavailable; showing job events and DELEGATES to event watch,
inheriting that mode's behavior exactly - including the reason-event
suppression above, so a deferral that already has a blocker_deferred event
appears as the event and not as HOLD:.
gitmoot job watch --json carries the last hold observed during the watch as
held_reason, held_next_retry_at and held_suggested_action. They are named
for that distinction deliberately: the JSON object is emitted once the job has
settled, so a field called why_stuck would assert a condition that is no longer
true. All three are omitted when no hold was observed.
For a terminal (succeeded, failed, blocked, or cancelled) job whose
recorded worktree still has a locally observable process, job list reports
LIVE PROCESS: worktree still has an active process and job show prints the
same detail as process_active: worktree still has an active process; their
JSON forms carry process_active: true. The badge is omitted when no live
process is observed, when local process liveness is unavailable, or while the
job is still non-terminal.
For an implement job, job show and job list --json expose Gitmoot's own
post-agent delivery verdict as delivery_status: delivered means the job's
result decision was implemented and either a completion marker is paired
with a persisted non-zero pull request, or the job has no advance events at
all and already carries a persisted non-zero pull request; pending means
delivery is in flight or scheduled for retry, and blocked means the latest
delivery attempt blocked. A completed advancement alone (e.g. an implemented
result that produced no PR, or a non-implemented decision) does NOT count as
delivered — the field is omitted in that case, never a false delivered. The
field is derived at read time
from the existing advance_* job events and pull-request payload; it is omitted
for non-implement jobs and legacy/unknown states. This field, not the agent's
free-text result summary, is authoritative about commit/push/PR delivery because
Gitmoot performs that step after the agent turn ends.
For a running job dispatched through a PID-aware runtime runner, the payload
records runtime_pid plus a process-start identity. job list --json and job show --json derive runtime_process_active: true means that exact process is
alive, false means it is confirmably gone (including PID reuse), and an omitted
field means unknown because no PID/identity was recorded or process inspection
is unavailable. This is direct per-job ground truth and is separate from the
terminal worktree-scanning process_active badge above.
Operational blockers auto-retry (#532): a delivery failure classified as
runtime_auth or runtime_quota does not fail the job terminally — the
daemon re-queues it as deferred with a bounded retry budget and a hold
until the earliest retry time. gitmoot job show --json carries the
blocker_class and attempt count, and over the [events] stream a
job.deferred follows the job.failed (making it non-terminal; see the
event stream). A job that "failed then reappeared as queued"
is the deferral working, not a bug.
A capability refusal is the one class that does not retry (#1821). When a
delivery fails because the runtime could not be executed at all - its binary is
absent from the seat's PATH, or resolves to a published unavailable shim that
exits 126, or the sandbox could not resolve the target - the job terminates
blocked with class runtime_unavailable and a
blocker_runtime_unavailable event carrying the remedy, and it records no
retry instant or attempt count. It is the only operational class whose
condition does not clear on its own: the others wait out a provider window, an
outage or a lock, while a missing executable waits for an operator, so
re-dispatching it walks into the same wall. List them with gitmoot job list --state blocked.
When a runtime session ends without producing a gitmoot_result envelope —
the CLI process crashed, exited non-zero, was signal-killed, or completed but
never emitted a valid envelope even after repair attempts — the job records
failure diagnostics (#806): a phase marker (launched = died before any stdout,
streaming = died mid-output, result-parse = every delivery completed but no
valid envelope was found, recovery = daemon recovery proved the durable runner
was gone), the process exit_code or terminating signal,
a redacted stderr tail (hard-capped at 4 KB; redaction runs over the full
text with the same token-redaction rules as job comments before the tail is
cut, so a secret can never leak partially), the delivery_error — the
engine's own terminal error for the delivery, i.e. the exact line the daemon logs
as job <id> failed: <err> (#1620) — and the runtime session id when one is
known. gitmoot job show prints a failure_diagnostics: block,
job show --json carries payload.failure_diagnostics, and gitmoot report bug includes a "Failure diagnostics" section. Successful jobs never store one,
and a retried job clears the previous run's crash report. Terminal failed
events embed the same bounded, redacted stderr tail for durable triage.
delivery_error is what distinguishes faults the row otherwise collapses into
one identical phase: streaming, exit_code: 1 — a model/CLI mismatch (400 invalid_request_error: The '<model>' model requires a newer version of Codex.)
and a compaction-endpoint 404, say, need different remedies but used to look
the same on the job row because the runtime's real error existed only in
journalctl --user -u gitmoot-daemon. It is a different source from
stderr_tail (which is the runtime CLI's own stderr, often just an echo of the
skill system prompt) and never replaces it; both go through the identical
redaction and 4 KB bound, because a provider error routinely carries a URL, a
request id, or a token. A delivery that failed before the adapter reported any
session evidence records delivery_error with no phase line, and a
delivery that failed with an empty error records no field at all.
Jobs stuck in running are backstopped too, but transcript silence alone never
kills work. The recurring same-boot sweep moves running to failed only when
all three facts agree: updated_at is frozen past the staleness window, the
default-on job log is byte-identical across two sweeps and older than
[daemon].quiet_kill_after (default 45m, minimum 5m), and the recorded runtime
PID is dead. A live PID with the recorded /proc start-time identity is an
absolute veto however quiet the log; a recycled PID records an identity-mismatch
event and leaves the job running. Legacy rows without a runtime PID require
twice the quiet threshold and no held runtime lease.
On Linux, runtime commands start as the leader of a dedicated process group;
the OnPID write persists that PID, its /proc start time, and the PGID before
the runner waits. Context cancellation signals the whole group (TERM, then
KILL). After a same-boot liveness verdict wins the running → failed
transition, cleanup signals the recorded negative PGID only when the current
group leader still has the persisted start time. A mismatch sends no signal and
records pgid recycle suspected, orphans leaked. Daemon death does not itself
reap the process group. A descendant that deliberately calls setsid escapes
the recorded process group; reaping such escapees remains an explicit
out-of-scope residual.
The staleness age leg is tunable via the
GITMOOT_STALE_RUNNING_AFTER environment variable; the smallest honored value
is 1m — below-1m, malformed, or non-positive values are rejected (with a
one-time warning) in favor of the 30m default rather than clamped (#560). It is a
crash backstop, not a kill deadline.
Migration note: daemon recovery now fails interrupted jobs
As of #1308, daemon-owned jobs recovered after a restart are failed, never
silently requeued. A changed Linux boot id, an expired runtime-session lease,
or the three-leg stale liveness predicate produces a job_recovery_failed event
that names the cause and recovery age since the row's last durable update and
carries the redacted last 4 KB of the retained job log when available. A startup
row with job_kill_pending but no
terminal event is likewise failed with the explicit reason daemon died mid-kill. Inspect the evidence, then use gitmoot job retry <job-id> when a
rerun is appropriate. session-* jobs are unchanged because they execute
outside the daemon.
Configuration audit: this migration makes no new config value load-bearing.
GITMOOT_STALE_RUNNING_AFTER still controls the age leg and
[daemon].quiet_kill_after still controls the byte-silence leg; only the final
recovery action changed from implicit requeue to evidence-bearing failure. After
a reboot there is no age wait: the daemon detects the changed kernel boot id on
startup and each recovery sweep (Linux only; other platforms use lease/age
recovery).
gitmoot job kill <root-job-id> is the operator kill switch for a runaway
delegation tree: it terminates the tree identified by its root job id
gracefully. In-flight jobs finish normally; the coordinator's next continuation
is routed through the graceful finalize path (synthesize what completed → stop)
and the daemon stops starting queued children of that root. See the
termination bounds for how it relates
to the other delegation backstops.
gitmoot job cancel <job-id> is the single-job abandon verb. It dismisses a
queued, running, or blocked job (a blocked job is one paused awaiting a
human — an operator permission gate or an unrecoverable blocker — so dismissing it
is the same abandon intent as cancelling a queued/running one; #631). Cancel is a
single-row transition: it does not propagate to a delegation tree, touch task
state, or set the killed flag — abandoning a whole tree is gitmoot job kill.
Cancelling also releases any resource locks the cancelled job still owned —
including a stranded runtime:<rt>:<session> lock left behind when a foreground
gitmoot agent ask was killed — so the next ask on that agent does not wait out
the lock TTL before it can run. Dismissal is reversible: gitmoot job retry
accepts a cancelled job, so a mistakenly dismissed one can be resurrected.
gitmoot job cancel --state blocked is the bulk form for clearing a backlog
of blocked jobs. Only blocked is accepted for --state (queued/running jobs
have single-job cancel; terminal jobs have retry). Narrow the selection with
--older-than (a Go duration like 168h, or a convenience <N>d days suffix
like 7d; age is measured from when each job became blocked), --repo owner/repo
(matches the job's payload repo), and --agent name. The bulk form is a
dry-run by default — it prints the matching jobs (id, agent, repo, age) and
exits without cancelling anything; pass --yes to actually cancel the selection.
Each selected job is dismissed through the same per-job job cancel path, so its
locks are released too. <id> and --state are mutually exclusive, and
--older-than/--repo/--agent require --state. gitmoot doctor warns when
blocked jobs older than 30d have piled up and prints this exact command.
To automate the sweep, set [orchestrate].blocked_ttl to a positive Go duration
(e.g. blocked_ttl = "168h"): the daemon's housekeeping tick then dismisses any
blocked job whose blocked-transition timestamp is older than the TTL, through the
same CancelJob abandon path (recording a distinct blocked_ttl_expired job
event so a TTL auto-expiry is distinguishable from a manual cancel). It is off
by default — an empty or 0s value disables it (a negative value is rejected),
because a blocked job is a human-awaiting decision that is never auto-discarded
unless you opt in. This is distinct from [orchestrate].escalation_ttl, which
auto-finalizes a whole paused delegation tree and is on by default (24h);
blocked_ttl dismisses a single blocked job and is off by default.
Native task auto-merge is enabled by default only behind an exact-head approved
review and green SHA-scoped commit statuses/check-runs. A miss parks the task as
awaiting_human_merge and records an org escalation. It selects the
most-specific live role whose scope matches the repo and addresses that role's
nearest live chart ancestor. With no live scope match it addresses a live
owner; a root match addresses itself. With no live upward recipient, the gate
fails closed without journaling an addressed note. Archive filtering does not
predict delivery; org validate reports missing wake routes and pane bindings.
Set [repos."owner/repo".merge_gate] auto_merge = false as an explicit kill-switch;
that deliberate hold does not escalate. Pipeline allow_auto_merge is independent.
When review independence cannot be verified, the merge gate names the evidence
it observed: no implement job for the task, implement jobs that do not match the
task identity, a matching implement job with no agent, or a malformed implement
payload. All four remain fail-closed. The no-job case is an attribution gap,
not a failed independence check: the gate cannot establish who implemented,
which is a different fact from "the reviewer implemented this", and it is the
expected state for in-session (pane-driven) implementation, where no engine
implement job exists. The approval is not disqualified. The remedy is runnable
by the implementing lane and needs no coordinator: record the durable
attribution row with gitmoot job record --agent <implementing-agent> --repo <owner/repo> --type implement --decision implemented --task <task-id> --pr <number> --head-sha <sha>, then re-evaluate. Session-recorded jobs (#657)
create rows with Type == "implement", which is exactly what the gate reads. If the work was done in
session by an org role that is not a registered agent, pass --acting-role <role> instead of --agent: attribution is then the role, validated against the
configured org registry, and the gate still enforces independence against it.
Never record an agent that did not implement, and never record the reviewer.
Merge-gate retries are automatic while the daemon is running. Retryable states,
such as a busy base-branch merge queue or an active job on the PR branch, are
retried on the next daemon poll tick. The default poll interval is 30s unless
the daemon was started with a different --poll. When an external
system owns the merge decision, set GITMOOT_DISABLE_NATIVE_MERGE_GATE=1
(also true/yes/on; #545): Gitmoot then abstains from its native merge
gate — fail-closed, it never merges gatelessly; the external gate makes the
call.
Draining before a deploy
gitmoot daemon drain stops the daemon claiming new work so a busy fleet can
reach the idle window the deploy recipe requires (#1207):
gitmoot daemon drain # stop claiming, wait up to 15m for in-flight jobs
gitmoot daemon drain --timeout 30m # wait longer
gitmoot daemon drain --clear # resume claiming, AFTER the restart
Queued work is not lost. Draining pauses selection at the same choke point the disk guard uses, so queued jobs stay queued and resume when the drain clears.
The command exits 0 only when no engine-dispatched job is in flight - that is the point at which a restart is safe. If jobs remain past the deadline it exits 1 and NAMES them rather than killing or parking them: a CLI cannot see the state of work it did not start, and terminating it is the loss drain exists to prevent. Drain stays active meanwhile, so the set can only shrink.
Session-recorded jobs (session-*) are excluded from the wait. They run outside
the daemon process, hold no lease, and may legitimately stay running for hours -
counting them would make the drain never finish.
The drain sentinel is a file under the gitmoot home, not a database row, so it still reads when the store is contended - which is exactly when a deploy is likely to be waiting.
--clear is required after the restart. The sentinel outlives the process;
a new daemon started while it exists will claim nothing.
gitmoot job run <id> is intentionally not gated by drain. It calls the
worker directly rather than going through the scheduler's queued-job selection,
so it is the one path that starts work on a drained daemon. That is deliberate:
it is a manual, explicit, single-job operator command, and an operator who types
it during a drain means it. A job it starts is still counted by an in-progress
daemon drain wait, because the job transitions to running; the only gap is
the ordinary race where it is invoked after a drain has already reported success
and exited.
Findings Ledger
gitmoot findings reports the #1822 findings ledger. Two shapes:
# Per-repository summary: who is recording obligations and who is answering them.
gitmoot findings
gitmoot findings --json
# Individual rows for one pull request, with the UIDs a reviewer must cite.
gitmoot findings --repo owner/repo --pr 12
gitmoot findings --repo owner/repo --pr 12 --json
--repo and --pr are given together or not at all. The store keys findings
on (repo, pull_request) and a finding UID is unique only within one repository's
pull request, so an unpaired flag cannot identify a set. It is refused rather than
rendered: an earlier revision accepted --repo alone, silently listed the
pull_request = 0 rows, and printed no findings recorded for a repository full
of them.
Rows are folded to the latest observation per finding, using the engine's own
rule (latestObservation), so the listing shows current state rather than the
append-only log. A listing that folded differently from the gate would disagree
with it about which obligations are open.
STATE is the last recorded observation and is not the same question as "does this
still block": an answered finding can still be mandatory at a later head if the
files its relevance keys name changed again. Only the merge gate decides that, and
the command says so in its own output.
A failed review can still record a source finding when it supplies executed evidence or a static locator and rationale. Quoted-only output from a failed review stays a job-level protocol/runtime failure; it does not become a code finding or merge obligation.
The row listing exists because the obligation brief is byte-bounded. Once its
budget is exhausted the brief omits mandatory UIDs and tells the reviewer to
consult the ledger, and until this command could print rows there was no supported
way to do that: the summary reports counts, and a read-only review seat has no
database access. Cite a UID verbatim as continues_uid to continue a finding -
typing its label mints a new one instead.
Asking what the gate still demands
--at-head answers a different question from the row listing, and the difference
is the reason it exists (#2097).
gitmoot findings --repo owner/repo --pr 12 --at-head <sha>
gitmoot findings --repo owner/repo --pr 12 --at-head <sha> --json
The rows above print STATE, the last recorded observation. --at-head prints
the obligations the merge gate would still demand at that head, which is not
derivable from any state column and differs in both directions:
- an open finding recorded at an earlier head remains an obligation when that head is the same as or an ancestor of the target head. A finding observed only on a proven-divergent branch line is not an obligation for the rewritten tree;
- an answered finding on the same commit line becomes mandatory again when the diff since the answer touches its relevance keys, so a state filter is blind to exactly that row.
It is not a reimplementation. It calls LedgerObligationsAtHead through
LedgerResolvers.ScopeFor, which the type documents as the only production path
to a LedgerScope, built by the same constructor the review brief and the merge
gate already share. The command therefore cannot answer differently from the
thing that blocks.
--at-head requires --repo and --pr: obligations are computed for one pull
request at one head, and the refusal names that combination rather than only
calling the input invalid.
Degradations are printed, including beside an empty list. LedgerScope
degrades rather than failing. Without an ancestry resolver, observations cannot
be limited to the target commit line. Without a changed-file resolver, answered
findings stay advisory and simply do not appear, which under-reports. The engine
records those as task events; this command has no task, so it prints them as
degraded: ... lines. An empty obligation list and an instrument that could not
look otherwise read identically, and the empty one reads as good news.
Finding merged pull requests that still carry obligations
gitmoot findings --merged-unresolved
gitmoot findings --merged-unresolved --repo owner/repo
gitmoot findings --merged-unresolved --json
Lists pull requests that merged while still carrying unresolved findings at their branch head. The report is the union of two sets, and they are not the same question:
- the obligations
LedgerObligationsAtHeadwould demand, the gate's own predicate; - findings whose latest observation at that exact head is still open, which
the gate's
dischargedAtHeadstep deliberately removes.
The second set exists because the gate asks "what must a new review at this
head still observe", and a row already recorded there has been observed. That is
correct for the gate and wrong for this report, which asks what was unresolved
when the pull request merged - the case where a P1 sits at the exact head
that merged. Those rows are labelled still open at the merged head so the two
sets stay distinguishable. The report and the gate can therefore disagree, by
design, and only in that direction: the report contains every gate
obligation and may add exact-head-open findings. When no finding's latest
observation sits at the merged head the second set is empty and the two
coincide.
It keys on the branch head, never the merge commit. Every merge in this repository is a squash, so the merge commit is a commit no reviewer ever observed: measured across 28 merged pull requests carrying findings, the ledger holds 19 observations at branch heads and zero at merge commits, and the two SHAs are never equal. Keying on the merge commit fails silently in both directions - filter the observations to it and the report is empty forever; pass it as the head and nothing is ever discharged, so every answered row reappears.
It reports what it scanned. scanned 7 repositories, 40 pull requests with findings, 26 merged - because an empty list from a scan of seven and an empty
list from a scan of zero read identically otherwise, and the second is an
instrument failure wearing a success message.
A pull request the forge cannot answer for is reported as a degraded: line
rather than skipped: an outage must not render every merged pull request as
resolved. WAIVED reflects the repository's current findings_consumption setting,
read at report time. It is not evidence about the merge: the setting may have
changed since, and no durable merge-time record of it exists, so reading it as
"the gate let this through by declaration" reverses history whenever the config
moved after the merge.
--merged-unresolved accepts --repo alone to narrow the scan and takes
neither --pr nor --at-head, and the refusal names that combination.
Result Checks
After a daemon-run job's gitmoot_result is parsed, Gitmoot runs a set of
deterministic, LLM-free binary checks over the parsed result — a
contract-hygiene audit that catches results that are technically valid but vague
or missing evidence. Each check is a yes/no question with an explanation:
-
implement — a result whose decision is
implementedmust list itschanges_madeand itstests_run. The engine resolves the effective checkout selected by the worker, persistspayload.result_observationfrom its diff, and failsimplement-changes-observedif a claim names a path absent from the diff, a claim names no file path, or the diff contains a path no claim mentions. A worktree-less delegation child instead persists the typed sourceexcluded_worktree_less_delegation_child; exclusion is observable and is not graded as diff evidence. -
review — a
changes_requestedreview must carryfindings(evidence). A terminal review verdict (approved/changes_requested) must additionally ACCOUNT FOR ITSELF (review-verdict-has-evidence): citefindings, name a substantivetests_run/changes_madeentry, or give a summary long enough to state why there was nothing to run. An entry counts as substantive when it is a phrase (a command with its outcome) or a single token long enough to name a real path or target — sotests_run: ["."]is not evidence, while an honest docs-only approval that explains what it read passes on its rationale. The check measures ACCOUNTING, not truth: it cannot detect fabricated evidence, and no deterministic check can. A coordinator fan-out — a review result that declaresdelegations— is exempt, because it announces a panel that has not reported yet and legitimately has nothing to record.A fan-out is never counted as a verdict, at any surface that reads a review decision. The merge gate excludes such a row from the verdict population, so an independent verdict at the same head still decides the PR; when the panel reported, the gate decides the slot on the delegates, counting only the LATEST attempt of each delegation so an approved retry supersedes a failed original; when it was announced and never dispatched, the gate reports
declared delegations that never reported. Pipeline auto-merge refuses a fan-out — a pipeline stage has its executabledelegationsstripped at the mailbox seam so a leaf can never spawn children, and the classification is preserved across that strip. Required-reviewer counting, the review-verdict wake, the canonical same-head verdict history and awaited review-verdict facts all skip fan-outs. The proof projector mints no approval claim and the rendered proof showsno verdictinstead of counting one.blockedandfailedreviews keep reporting their own cause. A coordinator is therefore dispatchable into a review slot, by CLI and by heartbeat. -
ask — the answer (
summary/artifact_body) must be non-empty and actionable. -
blocked (any action) — a
blockedresult must list actionableneeds. -
coordinator finalize — a finalize continuation must produce a substantive reconciliation summary.
The mode is set in config.toml and is warn by default:
[workflow]
result_checks = "warn" # off | warn | block (default: warn)
warn(default) — failing checks are recorded as aresult_checks_failedjob event (visible ingitmoot job events <id>andgitmoot job show <id>) and attached to the job detail (job show --jsonpayload.result_checksand the web dashboard), but the job still finishes on its own decision.block— a failing check additionally fails the job through the same terminal path a malformed result takes (opt-in, for strict workflows).off— the audit emits no check, event, or failure record. The worktree observation is still persisted when available; recording evidence is independent of whether a gate refuses it.
A result that passes every applicable check records nothing, so the audit is quiet on healthy jobs. Failed checks are also stored durably for later consumption as structured feedback; nothing consumes them today.
Pipelines
A pipeline (#681) runs a declared DAG of shell stages — a fixed, repeatable
multi-step flow — on demand or on an interval schedule. Each stage is an ordinary
queued job run through the shell runtime; a scan-based advancer folds each
stage's gitmoot_result decision and enqueues the stages whose needs have all
succeeded. Pipelines reuse the job queue, the result contract, and the heartbeat
scheduling idiom (durable next_due, overlap guard, missed-ticks-coalesce), and are
off by default.
Define a pipeline in a YAML file, then register and run it:
name: nightly-sync # required, name-safe token (letters, digits, - _)
repo: owner/repo # optional to register; REQUIRED to run
env_file: /root/.config/nightly-sync/env # optional operator-owned 0600 secret file
env: # optional inline NON-secret defaults
OUTPUT_DIR: /srv/nightly-sync
schedule: # optional interval schedule (no cron in v1)
interval: 24h
jitter: 15m
trigger: # optional pipeline-success chain
kind: pipeline
pipeline: upstream-name
stages: # the DAG, keyed by unique id and wired by needs
- id: source
cmd: "curl -sf https://example.com/data > data.json"
env_keys: [SOURCE_API_TOKEN]
- id: score
cmd: "python score.py data.json"
isolate: true # optional shell-only detached read-only worktree
needs: [source] # runs only after source SUCCEEDS
- id: triage # #757: an AGENT stage (exactly one of cmd|agent)
agent: reply-triager # an existing managed agent, run as a read-only leaf
action: ask # ask (default) | review — no implement
prompt: "Triage the scored data; block if a human is needed."
needs: [score] # upstream results are prepended to the prompt
- id: deploy
cmd: "rclone copy out/ r2:bucket"
needs: [triage]
timeout: 30m # optional per-stage job timeout
retry: 2 # optional; re-attempt a FAILED stage up to N times
gitmoot pipeline add nightly-sync.yaml --enable # validate + store; omit --enable to add disabled
gitmoot pipeline list [--json]
gitmoot pipeline show nightly-sync [--json] # registry view for a name
gitmoot pipeline run nightly-sync [--payload key=value ...] [--payload-json '<obj>']
gitmoot pipeline watch <run-id> [--timeout 10m] [--poll 5s] [--json]
gitmoot pipeline show <run-id> [--json] # run funnel for a "prun-…" id
gitmoot pipeline expose --schema schema.json <name>
gitmoot pipeline serve [--addr 127.0.0.1:8792] [--allow-remote]
gitmoot pipeline resume <run-id> [--from <stage>]
gitmoot pipeline cancel <run-id>
gitmoot pipeline enable|disable nightly-sync
gitmoot pipeline remove nightly-sync
Service API and offline proof
pipeline expose opts a shell-only, template-free pipeline into a bounded flat
input schema and prints its base64url bearer token once; only the SHA-256 digest
is stored. pipeline serve is a separate authenticated listener, loopback-only
by default. Valid typed inputs reach stages only as reserved
GITMOOT_INPUT_* environment variables in fail-closed detached worktrees.
Stages declaring env_keys, network access, or extra read/write authority are
rejected.
Admission atomically applies rate/concurrency limits and creates an unpredictable
128-bit run id.
Successful shell stages can deliver files from out/. Gitmoot collects them
before disposing the detached worktree, stores them as
artifacts/<stage-id>/..., and fails finalization if the run exceeds 64 MiB.
After success, an authenticated status GET finalizes the frozen pipeline bundle
with those files, proof.json, and verification.json; artifact proof nodes
commit each file's size and SHA-256 digest. gitmoot proof --verify <service-run-id> checks the persisted run/stage/job/result-hash and artifact
relationships offline; it does not rerun commands, query CI, or promote reported
tests.
The public /receipts/<run-id> page shows artifact names, sizes, and digests,
but its sanitized bundle omits artifact bytes. The authenticated service bundle
is the only download containing them. Token rotation revokes the old bearer
credential; disabling blocks new POSTs but does not revoke reads or polling of
accepted runs. Both bundles include the frozen #941 spec with full shell command
bodies and referenced environment-variable names, so never inline a secret
literal in cmd. Public capability receipt URLs remain public after token
rotation.
An enabled trigger.kind: pipeline pipeline fires once for each successful
upstream run. The durable cursor prevents duplicate fires across daemon restarts,
and adding or enabling the downstream arms it without backfilling old history.
Missing upstreams remain dormant. Self-reference, cycles, and schedule-plus-
trigger hybrids are rejected.
pipeline add validates the whole spec at add time and stores the raw YAML
verbatim plus a content hash; each run snapshots the hash and executes its
snapshot, so editing the file later never mutates an in-flight run. It also
auto-creates one hidden shell runner agent (pipeline-<name>-runner) that owns the
shell stage jobs; it is hidden from agent list and disposed by pipeline remove. A
stage may instead set agent + prompt to run a named managed agent on its own
runtime. Four agent-stage kinds: ask/review (#757, read-only leaves);
implement (#768, action: implement + write: true; mutates the repo, with only
implemented promising a PR and waiting for its stamp; other configured successes
settle immediately; never auto-merges); orchestrate (#758, orchestrate: true; a sub-tree
coordinator that fans out owned children and folds the synthesis); and gate (#768,
gate: pr_merged + source:, no agent; a jobless waiter that folds when the source
implement stage's PR merges). A read-only stage's needs result summaries are prepended
to its prompt, and a repo-bound read-only agent stage runs in its own detached
read-only worktree so same-repo stages parallelize without touching the live checkout.
The implement stage kind is the one implement surface #2203 left
declarable, and it cannot currently run. Its kind still validates, because
write: true and allow_scheduled_writes are #768's mutating-safety contract
and action: produce is mutating too — that half is live and was not touched.
What went is the pipeline-stage writable-worktree ALLOCATION plus the enqueue,
so a declared implement stage has nothing to run in, and neither a
source:-bound review nor a gate: pr_merged waiter will ever see the PR stamp
it was written to consume. Lifetime pipeline stage jobs: ask 5,229, produce 92,
implement zero — no pipeline ever created one. Whether pipeline add should
refuse the kind outright is the open decision in
#2213.
A non-service shell stage can opt in with isolate: true; it then runs in a
disposable detached read-only committed-tip worktree. Default false preserves the
shared checkout. Allocation failure records readonly_worktree_skipped and falls
back to that checkout; success adds GITMOOT_CHECKOUT=<live-checkout> to the shell
environment. This removes checkout-lock serialization, and each isolated stage also
takes a job-scoped shell runtime-session key (runtime:shell:job:<hash(job)>) rather
than the command-hash key, so same-repo stages run concurrently even when they share
the identical command (#1034). The field is rejected on agent/gate stages,
while service shell stages retain unconditional fail-closed isolation.
For shell-stage API credentials, set an absolute pipeline env_file and list
exact names or globs in each stage's env_keys. The file must be a regular,
operator-owned 0600 file outside Gitmoot state and managed checkouts; inline
env is for non-secret defaults. Missing/reserved keys and env_keys on an
agent/gate stage are rejected at add time. A stage with no list receives no
injected values. Values are read fresh at delivery for restart-free rotation;
the job payload stores only the path and expanded names, never file values.
A stage selector may also resolve to a shared keychain key the pipeline was
granted (see the gitmoot key registry below): the pipeline's own env_file
key always wins over a same-named grant, resolution is deny-by-default
(GITMOOT_* internals > own env_file > granted shared > inline default >
fail closed), and a grant revoked between enqueue and delivery fails closed. A
granted key registered with mode proxied (and configured with
gitmoot key configure) is never delivered as a value: the stage receives a
per-job placeholder plus a GITMOOT_PROXY_<KEY>_URL loopback lease pinned to
the configured HTTPS origin/base path, and Gitmoot re-checks the grant and
reloads the real value on every request. Produce stages may additionally
declare reads: — read-only Landlock input roots symmetric to writes: that
narrow the readable filesystem to an allowlist and can never expose the
Gitmoot home, the keychain, or the pipeline env_file.
Keychain registry (gitmoot key)
gitmoot key path [--json]
gitmoot key add <NAME> --mode injected|proxied [--json]
gitmoot key configure <NAME> --upstream <https-url> --auth bearer|header:<HeaderName> [--json]
gitmoot key list [--json]
gitmoot key show <NAME> [--json]
gitmoot key grant <NAME> --pipeline <pipeline> [--json]
gitmoot key revoke <NAME> --pipeline <pipeline> [--json]
gitmoot key rm <NAME> [--force] [--json]
The keychain is one operator-owned 0600 env file (default
~/.config/gitmoot/keychain.env, overridable via [credentials] keychain_path);
key path prints its location and status. The CLI registers names and
metadata only — there is deliberately no value flag anywhere, and secret
values never enter SQLite, argv, logs, or --json output. key add requires
the name to already exist non-empty in the keychain file; key grant requires
the pipeline and key to exist and refuses unconfigured proxied keys;
key rm refuses while grants exist unless --force (which removes metadata
and grants but never touches the file). Grants are deny-by-default and audited
by name: job payloads record {stage, name, source, mode} rows only. See
Runtime Ambient Credential Hygiene for the full custody
model, including the honest limits of proxied delivery.
Every agent stage receives a non-empty trigger payload as bounded, dynamically
fenced UNTRUSTED external data; shell stages receive exact
GITMOOT_TRIGGER_<UPPERCASE_KEY> environment entries. The full payload is retained
in the SQLite run row and normal job data. Triggered mutating stages additionally
require top-level allow_triggered_writes: true.
A stage signals its outcome by printing a gitmoot_result blob to stdout; the
advancer folds by the decision, never the job's exit state (changes_requested
is a succeeded job but folds as a stage failure by default):
- a decision in the stage's
success_decisions(defaultapproved/implemented/skipped) -> succeeded, dependents enqueue; blocked→ the stage blocks, itsneedspersist, the run parks blocked (downstream never enqueues, zero compute while parked);failed/ any other decision / a cancelled job / nogitmoot_result→ the stage fails (retried if budget remains), else the run parks failed.
skipped means the stage itself had no work and advances by default with a
[skipped: no work] summary marker. An explicit success_decisions list is
strict: omitting skipped makes it fail. A pr_merged gate whose terminal succeeded
source opened no PR parks blocked because there is nothing to wait for.
pipeline run prints only the run id (script-stable). Repeat --payload key=value
or provide one --payload-json string object to use the same validated trigger
input seam as the bridge; the forms are mutually exclusive. It ignores the enabled flag but
still needs a repo and refuses to start while a run is active. pipeline show <run-id> renders the text funnel (source OK -> score BLOCKED (needs: R2 token) -> deploy SKIPPED); a failed run also prints the exact gitmoot report bug --job <stage-job> command (never auto-filed). pipeline resume re-runs a parked run
from its halted stage (or --from) plus its transitive dependents while never
re-running a succeeded stage. A pipeline stage is a leaf: a stage result carrying
delegations[] never spawns children. See
Pipelines for the full workflow.
pipeline watch <run-id> blocks until terminal state, printing each stage state
change once. It exits 0 for succeeded, 1 for failed/blocked/cancelled, and 2
with still running when the timeout expires. --json emits the final
pipeline show --json summary without transition lines.
Routing Telemetry (Advisory)
Gitmoot records lightweight execution-grounded routing telemetry: one additive
row per job at its terminal transition capturing which combination actually ran and
how it turned out — repo, action, phase, runtime, model, agent, resolved
template_id + commit, terminal job_state, result decision + approval flag, a
coarse tests-run count, duration_ms, and input/output tokens (best-effort). Capture
is always on, additive, and fail-safe: it writes only to the new
routing_telemetry table and a telemetry error can never fail a job.
v1 is advisory only — nothing reads this back to change routing, and no automatic model/runtime override happens anywhere. It is a local feedback loop you inspect, not a global benchmark.
Inspect observed performance (read-only), grouped by (action, runtime, model, template):
gitmoot router summary [--repo owner/repo] [--action ask|review|implement] [--since 30d] [--json]
It reports per-group count, success rate, approval rate, median duration, and summed
tokens, always labeled "local observed performance, not a benchmark". --since
accepts a Go duration or an <N>d days suffix.
Optionally inject a bounded (≤12-line) observed-performance table into a coordinator's prompt. It is off by default; with it off, coordinator prompt assembly is byte-identical and no telemetry query runs during a job:
[router]
context_enabled = true # inject the advisory table into top-level coordinator prompts (default false)
The injected block carries the same "not a benchmark" disclaimer, is added only to top-level (coordinator) jobs, and never forces a route — routing stays advisory.