Checkpoint cadence
Checkpoint cadence makes unfinished work visible in Git and draft review. The
canonical policy is .config/agent/checkpoint.json; the shared evaluator lives
with the vendorable Lefthook scripts. It is separate from cadence.json, which
records dedupe audits.
The evaluator and Lefthook jobs are shared across coding agents. Native event adapters are client-specific; see agent lifecycle coverage for the tested and untested boundaries.
Adoption copies the evaluator bundle from adopt --lock-source-root. When that
source differs from the installed binary, pass the same root to audit --catalog-root
so the checkpoint audit compares with the bundle adoption actually installed.
Without an explicit catalog, audit compares with the running release's digests.
An incomplete selected bundle is an error when checkpoint files are present
(TestAuditCheckpointBundle_AfterAdoptFromSelectedSource).
Run lefthook run agent-checkpoint-tool between work chunks and
lefthook run agent-checkpoint-stop before ending a turn. Inspect the returned
due and actions fields: a successful observation can report work still due.
Native adapters interpret those fields rather than treating exit zero as closure.
Malformed policy, incomplete Git observations and failed forge lookups are errors.
When due, review the diff and stage only the task's public files. Run the required verification, then use a normal signed-off commit and gated push to an allowed work branch. Inspect existing PRs for that head/base before creating one with an explicit head, base and draft flag. Keep one ongoing review per branch; an existing ready PR also satisfies review visibility. Creation and synchronization must be read back against the actual pushed commit. A draft is a checkpoint, not merge approval or a substitute for the pinned receipt and PR gates.
The evaluator never stages files, commits, pushes or opens PRs itself. The agent
performs those steps within session authorization, with a reviewed message and
description. This avoids assigning unrelated edits to the current task. Private
.workingdir data and the receipt artifact are excluded from commit reminders;
staged private data remains an error. Existing Git privacy and verification
gates continue to apply independently.
Publication and hosted acceptance are separate observations. When the optional
require_checks policy is true, an exact-head PR can be publication_status:
present while review_status is failed, pending, draft_pending, or
missing and work remains due. The agent must resolve the failures or retain an explicit blocker;
the existence of a draft cannot close that work. passed requires at least one
successful check, no failed or pending checks, and success for every configured
required_checks name. Optional skipped/neutral jobs are reported separately;
they cannot satisfy a required name. A re-run of a job supersedes its earlier run on the
same head, as GitHub judges it: within one workflow, only the run of a job with the latest
startedAt counts (_latest_runs in .config/lefthook/scripts/checkpoint.py). Otherwise
duplicate names never hide a failure: two workflows sharing a job name, status contexts, and
runs without a workflow name or start time are all judged.
The hosted gates Praetor emits fail on a draft by design: their first step prints
an error annotation titled Gate not run on a draft with a fixed message and
exits 1, and the gate runs once the draft is marked ready
(When the workflow runs). On a
draft, the planner reads the annotations of the head commit's failed check runs
with one gh api graphql query and counts a failed check as draft_pending, not
failed, only when every failed run of that name carries exactly that title and
message (DRAFT_GATE_TITLE and DRAFT_GATE_MESSAGE in
.config/lefthook/scripts/checkpoint.py, held to internal/ghworkflow/hostedgate.go
by TestHostedGateDraftMarker_Boundary). Any other conclusion, annotation or
unmarked run, and the same marker on a pull request that is not a draft, stay
failed. draft_pending keeps the work due: it says the draft has not run its
hosted gates yet, not that they passed. The opt-in draft skip (hosted_gates.draft: skip) reports
the same marker: its result job runs a first step, only when the gate job was skipped, that prints
the same annotation and exits 1 (ghworkflow.RenderDraftSkip,
TestRenderDraftSkip_Boundary_CarriesTheDraftMarker), so a draft reads as draft_pending in either
shape. The skip is a trade-off: a draft still starts the <job id>-result job (it runs under
always()), the required check stays red on a draft, and only the gate job's own work is skipped.
A repository whose contract allows no job on a draft must declare the result jobs in the list of
jobs that may start there.
The check observer uses gh pr list --json statusCheckRollup, verified against
GitHub CLI 2.100.0. It rejects 100 or more contexts as incomplete because that
CLI query requests the first 100 contexts.
Unknown states and malformed output also fail closed. This establishes a bounded
check observation, not merge approval, review approval, fork synchronization, or
downstream deployment. Those require their own evidence and authorization.
Policy and local-only work
The version-1 JSON policy is strict. Its enabled, commit_after_minutes,
commit_after_files, on_stop, publish, remote, base, repository,
branch_prefixes and require_pr fields control this repository's behavior.
require_checks defaults to false for existing policies and requires both
publish and require_pr. required_checks accepts 1 to 64 distinct names
when enabled; an empty selection cannot identify absent required workflows.
When provided, require_checks must be true. Praetor enables it and names its
standards, compliance and security gates. Local-only repositories perform no
remote check requests.
On stop, the evaluator checks linked worktrees. The Go hook and native Python
adapter share the session helpers in .config/lefthook/scripts/common.py and
write an ownership record on tool events (PostToolUse, AfterTool) into
<git common dir>/praetor-checkpoint/sessions/<sha256(session_id)[:16]>.json,
recording worktrees whose resolved paths are named as whole paths or entered via
EnterWorktree. On Stop and AfterAgent, both paths pass PRAETOR_CHECKPOINT_SESSION
so only worktrees owned by the stopping session are judged. A gate scratch worktree at
<linked checkout>/.standards/worktrees/gate-<pid>-<n> is skipped only while
/proc/<pid>/cmdline identifies a live praetorctl gate process. This applies
beneath every linked checkout of the same repository. A dead, reused, or
unreadable PID keeps the scratch worktree due with a named owner-PID reason,
even outside the session record. Systems without /proc retain those worktrees.
Session records are bounded and atomically replaced; raw session IDs are never
stored. When the session environment variable is absent or no readable session
record exists (such as during manual stop runs or for clients without a session
id), the evaluator falls back to judging linked worktrees with Git admin-file
activity in the last 24 hours, and reports worktree ownership unknown: judged by
recent activity on each finding. The checkout running the hook is excluded.
Recent unnamed worktrees with public changes, commits not on a remote, or a
pushed advance without an open or merged PR keep the stop checkpoint due, unless
the worktree is clean and its net change since its merge base is already in the
remote base (its reversed patch applies to a temporary index of the base): a branch
landed through a squash or a train is finished without a pull request of its own
head. If checkpoint publication is disabled or has no configured repository, PR
state is skipped and reported.
To keep a worktree visible without blocking this turn, name it in
.workingdir/OPEN.md by its branch name, directory basename, or full path. A
name counts when nothing on either side continues it: branch=feat/x,
.../worktrees/x and a sentence ending in x. name the worktree, while
feat/x2, old-x and x.old do not. Include an id, branch, last step, and next step so the next turn
can resume it. The evaluator reads this ledger only and does not change it.
The optional backward-compatible enforce_batch_scope field enables native
file-tool admission once due: Claude Edit/Write and Gemini replace/
write_file must name one target path, which is allowed only when it is already
in the current public dirty batch. Confined .workingdir paths remain allowed
for private state and evidence. Before a checkpoint is due only the payload's
shape is checked, so a target or session directory outside the repository
passes; once due, both must be inside it. This is a bounded file-scope check; it does not
classify semantic repairs, inspect shell wrappers, or cover Codex apply_patch.
Tool events check public changes against file count or age of the HEAD commit.
Stop additionally applies on_stop, so a completed work chunk can be checkpointed
before its timer expires. A missing policy is explicitly disabled.
Set publish: false for local-only repositories: local checkpoint guidance still
runs, and no remote or forge lookup occurs. Publication requires an explicit
GitHub repository matching the configured Git remote. Missing base objects need
an explicit fetch; the checker never silently changes remote-tracking state.
An unpublished branch or a verified local advance can request a normal push.
Live remote advances and divergent commits instead request reconciliation, even
when local HEAD has no commits beyond the base. Missing branch objects and
shallow ancestry remain explicit verification errors. The observer uses live
remote tips and available commit history; cached tracking labels alone do not
establish whether publication is due.
On the configured protected base, a clean worktree also checks live ancestry:
base_current, base_local_ahead, base_remote_ahead, and base_diverged
distinguish its states. Differences request review, never a protected-branch
push. Base equality does not establish hosted release acceptance or fork rollout.
Forge failures, ambiguous PRs and stale PR heads cannot certify review visibility.
Other forge providers need a verified observation adapter before enabling this
publication policy. Go forge PR requests now separately support the draft flag.
Agent activation and failure handling
Codex and Claude use PostToolUse and Stop; Gemini uses AfterTool and AfterAgent.
The post-tool rows of Codex, Claude and Gemini invoke the adapter .config/agent/hooks/checkpoint.py
(the engine's own evaluator is the separate .config/lefthook/scripts/checkpoint.py),
which requires the actual Lefthook job's structured result; in this repository the Stop and
AfterAgent rows call praetorctl hook <client> stop through the launcher
.config/agent/hooks/praetor_hook.py, and the engine's stop path runs the ledger verify
and .config/lefthook/scripts/checkpoint.py --event stop itself, together with the prose-question check
(agent hooks). PostToolUse/AfterTool adds guidance without
replacing the completed tool's output. Stop/AfterAgent requests one continuation
for due or unverifiable work. If
the continued turn still cannot qualify, it ends with an explicit blocked reason
(continue: false, exit 0) instead of looping or claiming completion. Repair the reported cause and resume.
Repeated periodic reminders can occur until the checkpoint is reconciled.
praetorctl hook <client> post-tool|stop|pre-edit runs the same two evaluator
scripts (checkpoint.py, checkpoint_scope.py) directly, with no Lefthook hop:
agent hooks has the full contract. It
resolves its own Python interpreter from the hooks.python operator setting
(python3, python, then the two-token py -3 a stock Windows install
carries), so a host without any of the three gets a stated skip on the tool event
and a fail-closed block on stop rather than a silent [WinError 2]. The Lefthook
jobs try the default of that setting, the same three candidates, and prove one
before they run it
(the interpreter the hooks run).
The tracked Stop and AfterAgent
registrations of this repository call this path; the post-tool and pre-edit registrations
do not yet, so it exists alongside .config/agent/hooks/checkpoint.py and the
Lefthook jobs below until a later change re-points them and removes the Python adapters.
Stop/AfterAgent first checks the ledger, even when cadence is disabled: the engine
runs state sync --verify and state audit in Go (evaluateStopLedger), and the
no-engine fallback adapter runs the agent-state-stop Lefthook job. Both confirm a
valid, fresh existing ledger; neither silently writes a replacement snapshot. After changing the ledger or staging public work,
run praetorctl state sync . explicitly. Generated adoptee bundles still need
their own compatible CLI/state-job bootstrap; this repository's wiring does not
prove native activation or freshness enforcement for every adopter.
Detached jobs that cannot publish
A job that a session hands to another coding client in a sandbox runs the same Stop checkpoint, but it has no way to publish: the sandbox carries no push transport and no forge token. The checkpoint defers inside such a job instead of asking it to commit, push or open a draft pull request, and the session that launched the job commits its result. Three conditions must all hold, or the checkpoint behaves as everywhere else:
PRAETOR_DETACHED_JOBis set; the job runner sets it to the job's label;- the remote's push transport is disabled: ssh with a
GIT_SSH_COMMANDwhose program isfalse, or https with terminal prompts off and no credential helper; - no forge token is present: no
GH_TOKEN, noGITHUB_TOKENand no ghhosts.yml.
The marker alone, or a sandbox that only looks closed, changes nothing. The deferral is decided
before the checkpoint branch requirement, because a job never publishes from its own branch. The
result carries deferred: true, the reason, and publication_status: deferred_detached_job. The
agent adapter prints the reason as a message that does not block. The agent-state-stop job above
still runs first, so a broken or missing ledger still blocks
(_detached_job_deferral in .config/lefthook/scripts/checkpoint.py;
test_detached_job_without_ssh_transport_or_forge_token_defers,
test_detached_job_with_ssh_transport_keeps_commit_due and
test_detached_job_with_forge_token_keeps_commit_due in
.config/lefthook/scripts/test_checkpoint.py).
Review the exact project definitions through the client's native hook discovery
and trust flow. For Codex, scripts/dev_codex_hooks.py also supports inspection
and reviewed activation; it does not configure other clients. Configuration
and adapter tests establish available behavior; a live hook event establishes
activation for a specific client/session. Clients without native lifecycle
adapters can call the shared Lefthook jobs explicitly.
The fallback instruction is compiled from AGENTS.md into vendor context.
The bounded process and file handling currently targets POSIX workstations;
the native lifecycle integration is exercised on Linux.
Contracts are documented by Codex, Claude Code, and Gemini CLI. Gemini's adapter also follows the installed CLI 0.50.0 hook runner and schema. The adapter does not configure an autonomous timer, publish from a Git post-hook, or bypass verification when the network or signing setup is unavailable.