Skip to content

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_JOB is set; the job runner sets it to the job's label;
  • the remote's push transport is disabled: ssh with a GIT_SSH_COMMAND whose program is false, or https with terminal prompts off and no credential helper;
  • no forge token is present: no GH_TOKEN, no GITHUB_TOKEN and no gh hosts.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.