checkpoint

package
v0.22.171 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 26, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package checkpoint — `wip!:` autocommit primitive.

Autocommit is the operator's "save my work" escape hatch: stage the named files (or whatever's already staged) and commit with a `wip!:` Conventional Commits prefix that the autosquash flow in resolve.go later collapses into a real subject.

Why `wip!:` and not plain `wip:`:

  • The bang (!) marks a breaking-change-style attention flag in Conventional Commits, which is repurposed here as "this is not a real subject — squash me." `git rebase -i --autosquash` doesn't recognise `wip!:` natively (it knows fixup!/squash!), so resolve.go does the recognition itself by rewriting the todo list before handing it to git. (ideator-skip)
  • The trailing-bang form keeps the prefix structurally close to the existing `feat!:` / `fix!:` shapes the operator's eye is trained on, so `git log --oneline` reading stays uniform.

Anti-pattern guard: Autocommit is intentionally NOT exposed as a background daemon or hook — it's an explicit operator/agent call. Auto-saving every Edit would race the rules engine's pre_commit gate and bury intentional commits under noise. The CLI surface (e.g. `clawtool checkpoint save`) layers on top in a follow-up; this commit ships the helper only, per the autodev scope decision.

Package checkpoint — git commit + safety net for clawtool.

Per ADR-022 (drafting): the operator's "checkpoint" umbrella covers Commit (this file), autocommit, doc-sync rules, snapshot/ restore, and dirty-tree guard. v1 ships only the Commit primitive — Conventional Commits validation, hard Co-Authored-By block, and a pre-commit rules.Verdict gate. The richer pieces (autocommit, snapshot, guard) layer on top in subsequent commits.

Lives in internal/checkpoint, NOT internal/agents/biam — Codex's architectural review (BIAM task a3ef5af9) was explicit: "Do not reuse BIAM for checkpoint state. The overlap is 'SQLite exists,' not semantics." Checkpoint state is per-repo + per-session, not per-agent-task.

Package checkpoint — Guard middleware (defense-in-depth atop ADR-021 Read-before-Write).

The contract:

  • ADR-021 (Read-before-Write) catches the most common failure mode: an agent overwriting a file it never Read this session. The guard there is per-file and per-session.
  • This Guard is per-process, file-agnostic, and counts EDITS, not files. It answers a different question: "how many mutations have piled up since the last checkpoint?"
  • When Enabled = true and the counter reaches N (default 5), the next pre-edit Check() returns ErrCheckpointRequired. The Edit/Write tool surfaces that error verbatim so the operator (or agent) knows to call `clawtool checkpoint save` (autocommit) or land a real Conventional commit before continuing.

Why it's defense-in-depth and not the primary guard:

An agent can bypass Read-before-Write with the loud `unsafe_overwrite_without_read=true` flag (used legitimately for scaffolded files, force-rewrites, etc.). Once that escape hatch fires, RbW is silent for the rest of that file's lifetime in the session. Guard layers a second invariant on top — even if every Edit slipped through the bypass, you can't have run more than N uncheckpointed mutations in a row. The operator still gets a visible \"please checkpoint\" stop the moment things drift far from a known-good state.

Wiring (intentionally minimal — see autodev task notes):

  • The Edit / Write tool calls Guard().OnEdit(path) before mutating, then Guard().Check() to decide whether the edit proceeds.
  • The autocommit primitive (Autocommit in autocommit.go) and the Commit primitive call Guard().OnCheckpoint() on success to reset the counter.
  • Tests call Reset() in a t.Cleanup so the package-global instance doesn't leak counter state between cases.

Concurrency: Guard is safe for concurrent callers — the daemon's MCP surface fans Edit/Write calls across goroutines. A single sync.Mutex protects the counter, the Enabled flag, and the max-edits threshold. Hot-path overhead is one mutex acquire + one int compare; an absent / disabled config short-circuits to nil immediately.

Scope: this package owns the in-process counter and the Check() predicate. It does NOT own the wiring into Edit/Write — that lands in internal/tools/core. The hook is exported (OnEdit, OnCheckpoint, Check) so a future BIAM / autopilot caller can reuse the same instance without re-deriving the counter from git history.

Package checkpoint — autosquash resolve.

Resolve collapses every `wip!:` checkpoint commit between <base> and HEAD into the most recent non-`wip!:` commit on the branch, preserving its subject + body.

Earlier revisions of this file shelled out to `git rebase -i --autosquash` with a custom GIT_SEQUENCE_EDITOR shell script that rewrote `pick <sha> wip!: …` lines to `fixup <sha>`. That approach was fragile across CI environments — the sequence editor was a /bin/sh + sed wrapper, and on at least one CI runner config the rebase silently no-op'd (exit 0, but the todo rewrite never landed; HEAD unchanged). Reproducing under macOS-latest required jumping through too many hoops, and the failure mode (silent success) is exactly the wrong shape for a primitive that mutates history.

The new mechanism is pure Go + plumbing-level git commands — no shell script, no rebase, no interactive editor. Algorithm:

  1. List <base>..HEAD as (sha, subject) pairs, oldest first.
  2. Walk the list and group commits into "fold groups": one non-wip commit followed by zero or more contiguous wip!: commits. The non-wip commit's subject + body is preserved; the wip commits' diffs are folded in via the LAST commit's tree (so all checkpoint progress is retained).
  3. Replay groups onto <base> using `git commit-tree`: - parent = current synthesised HEAD (starts at <base>) - tree = the LAST commit in the group's tree (so the final wip diff state wins, exactly like fixup) - msg = the FIRST (non-wip) commit's full message (subject + body, preserving Co-Authored-By trailers if present on the real commit) - author / committer info preserved from the non-wip commit so `git log` blame stays meaningful.
  4. `git update-ref HEAD <last_synth_sha>` — atomically swing the branch tip to the new history.
  5. `git reset --hard HEAD` to bring the working tree + index into sync with the squashed tip.

Edge cases handled:

  • Branch contains ONLY wip!: commits since base → return an error; there's no real commit to fold into. The operator should manually rename the first wip!: subject to a real Conventional Commits form, then re-run.
  • Branch contains NO wip!: commits → no-op; return nil immediately without touching git state.
  • Multiple non-wip commits with wips between them → each real commit anchors its own fold group; the structure is preserved (only wips collapse, real commits stay distinct).

Anti-pattern guard: Resolve does NOT push. The pre_push rule (wip-on-protected-branch) is the safety net for forgetting to resolve; Resolve itself is purely local-history surgery.

Index

Constants

View Source
const DefaultMaxEditsWithoutCheckpoint = 5

DefaultMaxEditsWithoutCheckpoint is the package-level default applied when GuardConfig.MaxEditsWithoutCheckpoint is zero or negative. Five edits is the operator's chosen cadence — small enough that a runaway agent can't churn an entire package before being caught, large enough that interactive editing doesn't trip the gate after every tweak.

View Source
const WipPrefix = "wip!:"

WipPrefix is the literal subject prefix Autocommit prepends. Exported so resolve.go and the pre_push rule can reference one canonical string instead of duplicating the literal across the package.

Variables

View Source
var ErrCheckpointRequired = errors.New(
	"checkpoint guard: too many uncheckpointed edits — call `clawtool checkpoint save` " +
		"(or land a real Conventional commit) before continuing. This is defense-in-depth " +
		"atop Read-before-Write; pass --no-guard / disable [checkpoint.guard] enabled to " +
		"silence the gate.",
)

ErrCheckpointRequired is the sentinel returned by Check when the edit counter has reached the configured threshold. The Edit / Write tool surfaces the message verbatim so the operator's chat / terminal sees the actionable hint.

Functions

func Autocommit added in v0.22.135

func Autocommit(ctx context.Context, files []string, msg string) error

Autocommit stages files (or uses the existing index when files is empty) and commits with a `wip!:` prefix prepended to msg. When msg already starts with `wip!:` (or `wip!: `), Autocommit does NOT double-prefix — the operator's intent is preserved verbatim.

Validation: msg must be non-empty after trimming. The Conventional Commits validator is bypassed (the prefix would fail it anyway — `wip` is not in the allowed type set), and the Co-Authored-By hard-block stays on so AI-attribution still can't sneak in via a checkpoint commit.

Files semantics:

  • empty slice → don't stage anything new; commit the current index. This is the "I already ran git add" path.
  • non-empty → run `git add -- <files>` first.

Returns nil on success; the underlying `git commit` error otherwise (including "nothing to commit" when the index is clean and AutoStageAll wasn't used).

func BoolPtr added in v0.22.135

func BoolPtr(b bool) *bool

BoolPtr is a small ergonomic helper for callers that want to pass an explicit Sign override without writing `v := true; …`.

func CheckDocsync added in v0.22.135

func CheckDocsync(cwd string, changedPaths []string) []string

CheckDocsync returns the subset of changedPaths that are Go source files whose sibling Markdown doc (same basename, `.md` extension, same directory) exists on disk under cwd but is NOT itself in changedPaths.

Rules of the predicate:

  • Only `*.go` files are inspected. `_test.go` files are EXCLUDED — tests don't have a documentation contract, and including them would fire on every test edit.
  • Generated files (`*.pb.go`, `*_gen.go`, `zz_generated_*.go`, anything matching the well-known codegen suffixes) are EXCLUDED — they're regenerated, not hand-edited, and have no sibling doc.
  • The sibling doc is the file at <dir>/<base>.md where <base> is the Go file's name minus the `.go` suffix. We don't fall back to README.md or package-level docs — the rule's tight-pairing semantics (one Go file ↔ one .md) is what makes the violation actionable. Operator can opt out via severity=off if a project doesn't follow the convention.
  • Sibling existence is checked with os.Stat(cwd + "/" + siblingPath). When cwd is empty we use ".".
  • Forward-slash paths are normalised to filepath separators before stat-ing so the function works the same on Linux and Windows; the returned violation paths preserve the input's forward-slash shape (callers compare against rules.Context.ChangedPaths, which is forward-slash).

Returns nil (not an empty slice) when no violations are found — callers can use len(result) == 0 OR result == nil interchangeably.

func CurrentBranch

func CurrentBranch(cwd string) string

CurrentBranch returns the symbolic branch name (or empty when detached). Used in CommitResult for the operator's render.

func HasWipPrefix added in v0.22.135

func HasWipPrefix(msg string) bool

HasWipPrefix reports whether the first line of msg begins with the `wip!:` literal (with or without the trailing space). Used by PrependWipPrefix to avoid double-prefixing and by the pre_push rule predicate path to detect a checkpoint subject.

func IsClean

func IsClean(cwd string) (bool, error)

IsClean reports whether the working tree has no unstaged or untracked changes (git status --porcelain returns empty). When AllowDirty is false, the Commit caller refuses to proceed if this returns false AFTER staging.

func IsGitRepo

func IsGitRepo(cwd string) bool

IsGitRepo reports whether cwd is inside a Git working tree. We shell out to `git rev-parse --is-inside-work-tree` rather than walking up looking for `.git` because submodules and worktrees both make the directory layout non-trivial; let Git answer the question.

func PrependWipPrefix added in v0.22.135

func PrependWipPrefix(msg string) string

PrependWipPrefix returns msg with `wip!: ` prepended unless msg already begins with the prefix (case-sensitive — Conventional Commits is case-sensitive on the type, and our pre_push rule matches the literal string, so we don't normalise case here).

Exposed (capitalised) so internal/tools/core can reuse the same shaping logic when an MCP tool surface lands.

func Resolve added in v0.22.135

func Resolve(ctx context.Context, base string) error

Resolve runs the autosquash mechanism on the current working directory. See ResolveAt for the full semantics.

func ResolveAt added in v0.22.135

func ResolveAt(ctx context.Context, cwd, base string) error

ResolveAt is Resolve with an explicit cwd — useful for tests that operate on a temporary repo and don't want to chdir the whole process.

Returns:

  • nil on success (or no-op when no wip!: commits exist)
  • error wrapping the underlying git failure
  • error when every commit since base is `wip!:` (no real subject to fold into)

func Stage

func Stage(cwd string, paths []string, autoAll bool) error

Stage runs `git add` for each path. When paths is empty the caller may have set AutoStageAll, which is handled here too.

func StagedFiles added in v0.21.4

func StagedFiles(cwd string) ([]string, error)

StagedFiles returns the list of staged paths (relative to cwd, forward-slash). Empty when the index is clean. Used by the Commit tool to populate rules.Context.ChangedPaths so `changed(glob)` predicates see what's actually about to land.

func ValidateMessage

func ValidateMessage(msg string, opts CommitOptions) error

ValidateMessage runs every message-level check the operator configured. Returns nil when the message passes; otherwise an error naming the failed check first so a caller's error display reads cleanly.

Types

type CommitOptions

type CommitOptions struct {
	// Message is the proposed commit message body. Validated
	// against Conventional Commits unless RequireConventional
	// is false.
	Message string
	// Cwd is the repo root. Defaults to current directory.
	Cwd string
	// Files lists paths to stage before committing. When empty,
	// the existing index is used (operator stages manually or
	// via AutoStageAll=true).
	Files []string
	// AutoStageAll runs `git add -A` before commit. Default
	// false to avoid accidentally committing the world.
	AutoStageAll bool
	// AllowEmpty maps onto `git commit --allow-empty`. Default
	// false — empty commits are usually a bug.
	AllowEmpty bool
	// AllowDirty bypasses the working-tree dirtiness guard.
	// Default false — dirty trees during a commit usually mean
	// "you forgot to stage something or autocommit raced you".
	AllowDirty bool
	// RequireConventional enforces the Conventional Commits
	// shape. Default true (operator's policy); flip to false
	// for prototype repos that don't bother.
	RequireConventional bool
	// ForbidCoauthor hard-blocks any `Co-Authored-By` trailer.
	// Default true (operator memory feedback — never attribute
	// to AI). The flag exists so other operators using
	// clawtool can opt out; Bahadır's profile keeps it on.
	ForbidCoauthor bool
	// Push runs `git push` after the commit. Default false —
	// auto-push is loud and should be opt-in per call.
	Push bool
	// Sign controls `git commit -S`. The pointer is three-valued
	// per ADR-022 §Resolved (2026-05-02):
	//
	//   nil   → consult `git config --get commit.gpgsign` from cwd;
	//           pass -S when it returns "true", otherwise unsigned.
	//   &true → force-sign (per-call override, equivalent to the
	//           old bool=true behaviour).
	//   &false→ force-unsigned (per-call override; bypass operator
	//           git config when the caller explicitly wants an
	//           unsigned commit, e.g. a fixture commit in tests).
	//
	// The same propagation logic applies to `tag.gpgsign` for any
	// future tag command — see resolveSignFromGitConfig.
	Sign *bool
}

CommitOptions captures every input the Commit primitive accepts. The MCP tool layer (internal/tools/core/commit_tool.go) maps JSON args onto this struct so Validate / Run / Push stay pure and testable in isolation.

type CommitResult

type CommitResult struct {
	Sha         string    `json:"sha"`
	ShortSha    string    `json:"short_sha"`
	Branch      string    `json:"branch,omitempty"`
	Subject     string    `json:"subject"`
	Files       []string  `json:"files,omitempty"`
	Pushed      bool      `json:"pushed"`
	CommittedAt time.Time `json:"committed_at"`
}

CommitResult is the structured return shape.

func Run

Run executes the actual `git commit -m <msg>` and returns the new SHA + branch + subject. ValidateMessage MUST have run before this point.

type GuardHandle added in v0.22.135

type GuardHandle struct {
	// contains filtered or unexported fields
}

GuardHandle is the public middleware surface.

func Guard added in v0.22.135

func Guard() GuardHandle

Guard returns the package-wide GuardHandle. Returning a small indirection (rather than the *guardState directly) keeps the API surface narrow — callers see OnEdit / OnCheckpoint / Check only, and we can swap the backing store later (e.g. SQLite per repo) without changing call sites.

func (GuardHandle) Check added in v0.22.135

func (g GuardHandle) Check() error

Check is the gate. Returns ErrCheckpointRequired when Guard is enabled and the counter has reached the configured maximum. Otherwise returns nil.

The check fires AFTER the OnEdit increment so the Nth edit is the one that trips the gate (operator gets a hard stop the moment they exceed the budget, not one edit late). Edit/Write call OnEdit + Check in sequence — the post-OnEdit Check is the canonical pattern.

func (GuardHandle) Counter added in v0.22.135

func (g GuardHandle) Counter() int

Counter returns the current edit count. Used by tests + the future `clawtool checkpoint status` verb. Not load-bearing for the gate itself.

func (GuardHandle) Enabled added in v0.22.135

func (g GuardHandle) Enabled() bool

Enabled reports the current toggle state. Cheap; one mutex. Exposed so the Edit / Write hook can short-circuit the OnEdit + Check pair when Guard is off.

func (GuardHandle) OnCheckpoint added in v0.22.135

func (g GuardHandle) OnCheckpoint()

OnCheckpoint resets the edit counter. Called by Autocommit on success and by Run (commit.go) on a real Conventional commit. Safe to call when Guard is disabled — the counter just stays at zero.

func (GuardHandle) OnEdit added in v0.22.135

func (g GuardHandle) OnEdit(path string) error

OnEdit is the pre-mutation hook. Increments the edit counter when Guard is enabled; no-op otherwise. The path argument is accepted for future use (per-path debouncing, allowlist) but not consulted today — the v1 contract is "any mutation counts".

Returns nil unconditionally; Check() is the gate.

func (GuardHandle) Reset added in v0.22.135

func (g GuardHandle) Reset()

Reset is the test hook. Re-zeros the counter and restores the disabled-default state so a t.Cleanup keeps state from leaking between tests. Production code never calls this — flipping the toggle off-then-on via SetConfig is the supported path.

func (GuardHandle) SetConfig added in v0.22.135

func (g GuardHandle) SetConfig(enabled bool, maxEdits int)

SetConfig wires a GuardConfig-shaped value into the singleton. Idempotent. The caller is responsible for marshalling config.GuardConfig into the boolean + int pair — this package deliberately doesn't import internal/config to keep the dependency graph straight (config already imports atomicfile + xdg + toml; checkpoint shouldn't pile on).

maxEdits ≤ 0 falls back to DefaultMaxEditsWithoutCheckpoint so a literal 0 in TOML round-trips as "use default" rather than "disable" — the Enabled flag is the disable switch.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL