Skip to content

feat(binding): bind, adopt, and checkpoint sessions across repositories - #1959

Open
peyton-alt wants to merge 30 commits into
mainfrom
feat/session-binding
Open

feat(binding): bind, adopt, and checkpoint sessions across repositories#1959
peyton-alt wants to merge 30 commits into
mainfrom
feat/session-binding

Conversation

@peyton-alt

@peyton-alt peyton-alt commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

https://entire.io/gh/entireio/cli/trails/1018

What

Makes sessions follow the agent's activity across repositories instead of treating the hook's launch directory as permanent ownership. Covers sessions launched from a repo, from a parent/non-repo directory, repos created during a session, nested repos, linked worktrees, and cross-repo switches.

Related: #1439, #529, and #1098.

How

  • Machine-level binding record: stores the repositories touched by a session under ~/.config/entire/sessions/<session-id>.json, keyed by canonical git common-dir identity. Records evidence timestamps/counts, files, activation state, transcript cursor, and additive-adoption markers.
  • Activity-time evidence: collects absolute foreign paths from ToolUse and TurnEnd hooks. It also detects nested repositories among otherwise-kept paths and excludes registered submodules.
  • No-repo evidence: hooks launched outside any repository scan tool payloads and transcript deltas, then atomically commit evidence and the transcript cursor so failed writes never skip evidence.
  • Additive adoption: replicates the same session ID into every touched, explicitly enabled repository without retiring or mutating the source session. Writes are idempotent and protected by bounded session-state locks.
  • Per-repo checkpoints: replays the ordinary TurnEnd hook pipeline in every successfully adopted repository. Replays run in parallel; the repository owning the latest eligible activity receives the token delta and secondary repositories receive zero token delta.
  • Compatibility and safety: record schema version 3 prevents older binaries from dropping newer fields; resolution and repo counts are budget-capped; binding failures and panics never block ordinary capture; hook stdin is buffered once so evidence collection and Claude PostTodo handling can both consume it safely.

Current scope

This PR remains independently mergeable from the global-enable stack. On its own, adoption is deliberately limited to repositories with explicit repo-level Entire setup (.entire/settings.json or equivalent local activation).

When composed with the global-enable stack (#1953#1954#1955#1957#2030), the remaining integration is to let the foreign-repo eligibility check recognize global-tier activation and trigger the stack's lazy invisible setup before replication. That interaction is not claimed by this independent PR.

Still tracked separately:

  • retention/cleanup for machine-level binding records and their lock files;
  • server-side cross-repo session joining/deduplication by session ID before multi-repo transcripts are surfaced in the web UI.

Verification

At the current head, lint, unit tests, integration shards, Windows tests, binary-size/license checks, and both deterministic canary jobs pass. The binding-specific tests cover canonical identity, bounded resolution, no-repo cursor atomicity, additive/idempotent replication, multi-repo replay, primary token attribution, and state-lock timeout propagation.

🤖 Generated with Claude Code and Codex

peyton-alt and others added 3 commits August 11, 2026 13:27
New binding package, pure plumbing for activity-time session binding: a
cached resolver mapping absolute evidence paths to their repo (worktree root
+ absolutized common dir), and a flock-guarded session record under
userdirs.Config()/sessions recording which repos a session's activity has
touched. Nothing reads the record yet; the additive-adopt slice consumes it.

Review fixups: rev-parse now uses --path-format=absolute (symlink-canonical
CommonDir preserves the clone-wide-key invariant; pre-2.31 gits fall back to
a flagless path with EvalSymlinks canonicalization, selected by a once-per-
process git-version probe so misses cost one fork), mutateRecord refuses to
rewrite records with a newer schema Version and normalizes legacy/zero
versions upward on write, RecordBinding takes an Evidence struct instead of
adjacent (repo, enabled) args, the flagless fallback is pinned byte-identical
to the flag path by a direct test, and the ancestor walk bound + resolver
cache staleness assumptions are documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Out-of-repo absolute paths from transcript extraction and tool-use events
were silently discarded at FilterAndNormalizePaths; a collecting sibling now
diverts them to the binding resolver, and evidence landing in a different
clone is recorded in the machine-level session record (enabled-status noted,
capped per turn, best-effort). Zero behavior change for capture: kept paths
are identical, and empty-foreign turns add no IO.

Review fixups: resolution attempts are bounded per invocation (a turn full of
non-repo paths previously forked git once per distinct directory; now at most
16 resolutions AND at most 8 recorded repos, whichever trips first), the
own-worktree skip keys on the worktree root alone (two clones cannot share a
root) with the tap's git-common-dir plumbing removed entirely, the tap runs
under its own binding_tap perf span instead of polluting
filter_and_normalize_paths, and a deferred recover makes the never-block-
capture promise hold for panics as well as errors. The resolution budget
counts DISTINCT directories (filepath.Dir as a cheap proxy for the resolver's
cache key), so many paths inside one foreign repo spend it once and cannot
starve a later repo's evidence, and the panic log carries the stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@peyton-alt
peyton-alt requested a review from a team as a code owner August 11, 2026 21:18
Copilot AI lite review requested due to automatic review settings August 11, 2026 21:18

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit a99d5dd. Configure here.

Comment thread cmd/entire/cli/binding/record.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an observation-only “binding” layer that records evidence when an agent session touches paths outside the current repo, laying groundwork for later cross-repo adoption without changing capture behavior today.

Changes:

  • Introduces a new binding package to resolve absolute evidence paths to repo identity (worktree root + canonical git common dir) with caching and git-version-aware behavior.
  • Adds a machine-level, versioned session record store under the user config directory to persist cross-repo bindings (timestamps, counts, enabled-at-observation).
  • Taps TurnEnd and ToolUse hook paths to collect “foreign” (out-of-repo) paths and record distinct foreign repos with per-turn caps and panic containment.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
cmd/entire/cli/state.go Splits path filtering into “kept” vs “foreign” to preserve existing behavior while surfacing out-of-repo evidence.
cmd/entire/cli/settings/settings.go Adds IsSetUpAtRoot helper for checking .entire setup in an explicit worktree root (foreign repo enablement check).
cmd/entire/cli/settings/settings_test.go Adds unit tests for IsSetUpAtRoot.
cmd/entire/cli/lifecycle.go Hooks in foreign-path collection/recording for ToolUse and TurnEnd.
cmd/entire/cli/binding/resolve.go New resolver that maps absolute paths to repo identity via cached git rev-parse.
cmd/entire/cli/binding/resolve_test.go Tests resolver behavior (repo hits/misses, symlink invariants, worktree common-dir sharing, caching).
cmd/entire/cli/binding/record.go New session record store (versioned JSON, file locking, atomic write).
cmd/entire/cli/binding/record_test.go Tests record creation, upserts, version gate, and concurrent serialization behavior.
cmd/entire/cli/binding_tap.go Implements the bounded, panic-contained tap that resolves/records foreign evidence.
cmd/entire/cli/binding_tap_test.go Tests tap behavior (enabled/disabled repos, caps, misses bound, behavior-preservation pin).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cmd/entire/cli/binding_tap.go
Comment thread cmd/entire/cli/binding/record.go
Comment thread cmd/entire/cli/binding/record.go Outdated
peyton-alt and others added 8 commits August 11, 2026 16:04
Hooks firing with a non-repo cwd previously bailed before any evidence
could be read, so parent-dir launches (~/dev/acme over sibling repos —
the loudest #1098 scenario) produced nothing for binding to act on. The
bail now feeds a best-effort, evidence-only path: parse the event
(parsing is repo-free), extract file paths (tool-use payloads directly;
turn-end transcripts via a cursor stored in the machine session record,
CurrentRecordVersion 2), and record foreign repos through the existing
bounded tap. No session state, no checkpoints, no logging.Init, no repo
writes; non-evidence verbs cost one parse over today's silent bail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 01KZSG8Z1VZ7J5QF6C9V55N1PG
…gle-clock mutations

Three reviewed findings on the session-record store:

- 019ff357-aec4: BoundRepo entries are keyed by CommonDir, but a match
  from a different worktree of the same clone updated Enabled/counters
  while leaving the embedded RepoIdentity.WorktreeRoot first-seen,
  making the stored (WorktreeRoot, Enabled) pair incoherent. The match
  branch now refreshes the whole embedded identity with the flag.
- 019ff357-ecda: mutateRecord's flock acquisition blocked indefinitely
  on deadline-free hook contexts, contradicting the tap's never-block
  promise. The lock wait is now bounded at 2s (matching the strategy
  session-lock ceiling); on timeout callers Debug-and-skip as designed.
- 019ff358-01d1: UpdatedAt and First/LastEvidenceAt came from separate
  time.Now calls, so UpdatedAt could predate the evidence timestamps.
  One clock reading per mutation is now threaded through the mutation
  callback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The timeout's comment claimed tests shrink it, but the lock-contention
test deliberately runs the real 2s ceiling — keep the documentation
honest and the value immutable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iling

The single-clock check now asserts exact equality across all first-write
timestamps and documents that the callback signature — not this assertion —
is the real enforcement (consecutive Now() calls can share a tick). The
contention test pins recordLockTimeout at its contractual 2s and bounds
elapsed relative to it instead of a loose 10s window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The evidence tap only saw paths the FilterAndNormalizePaths clamp rejected
as outside the session repo's root. A repo NESTED under that root is
path-wise inside, so its files were always kept and never became evidence.
On a machine where $HOME is itself a git repo (a common dotfiles setup),
every path on the machine is inside the session repo when the agent runs
from ~, so the in-repo evidence path could never fire at all.

recordForeignEvidence now also receives the kept (repo-root-relative)
lists and, inside its existing recover/span/budget scope, runs a stat-only
ancestor walk per distinct kept directory: only when a .git entry (dir or
gitfile) exists strictly between the path's directory and the session root
is the path handed to the budgeted git resolver, which returns the
innermost repo — the same identity the no-repo branch records for the same
path. The no-nested-repos common case costs zero git forks, and nested
evidence shares the existing 16-distinct-dir/8-repo per-turn budget.

Repos registered in the session repo's .gitmodules are part of the parent
project and excluded; unregistered nested repos (the dotfiles case) are
recorded. The kept lists are only read — the capture inputs stay
byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 01KZVJD6E2D9WHJQFZGGZR3HHT
…clusion

Two review findings on the nested-repo evidence detection:

Per-root dedupe: nestedRepoEvidencePaths emitted one candidate per distinct
kept DIRECTORY, so a turn editing many directories of one busy nested repo
spent one git fork per directory — up to the whole 16-resolution budget on
redundant resolutions of the same repo (fresh hook processes have an empty
resolver cache, so every ToolUse hook paid it again), starving a second
nested repo later in the same turn. Candidates are now deduped by detected
nested root: one representative path, one fork, per repo.

Transitive submodules: only the session root's .gitmodules was consulted,
so a repo nested under a registered submodule path (the submodule's own
submodule) was recorded as foreign — against the exclusion's own "part of
the parent project" rationale. A detected nested root lying UNDER a
registered path ("/"-boundary prefix containment: vendor/sub covers
vendor/sub/inner but not the sibling vendor/subextra) is now skipped
without parsing every submodule's .gitmodules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 01KZVPYKT0WPGVB13G4F1K22EF
…cked mutation

scanTranscriptForeign advanced the transcript scan cursor in its own
record write BEFORE the evidence derived from that scan was recorded
(recordForeignEvidence -> RecordBinding, one write per repo). A failed
evidence write -- record-lock contention/timeout, transient I/O, a
killed hook -- left the cursor past transcript lines whose evidence
never persisted, and an advanced cursor means those lines are never
rescanned: silent evidence loss on the no-repo path (the #1098
parent-dir scenario).

Fix shape: binding.RecordEvidenceAndAdvanceCursor persists all of a
scan's evidence plus the new cursor inside the single flock'd
mutateRecord, so the pair is all-or-nothing. A failure writes neither;
the next turn-end rescans the same span, and because nothing was
recorded the retry cannot double-count -- no idempotence bookkeeping
needed. scanTranscriptForeign is now a pure read returning the scan
result, and resolveForeignRepos is split out of recordForeignEvidence
so the no-repo turn-end path can resolve repos before its one write.
Extractor-native cursor coordinates (claude=lines, gemini=message
index) and reset-on-truncation semantics are unchanged;
AdvanceTranscriptCursor is subsumed by the combined call and removed
(no remaining callers, so the split-write pattern cannot be reached).

Regression pinned by
TestRecordNoRepoEvidence_FailedWriteLeavesCursorForRescan: evidence
write fails (held record flock) -> cursor must not advance, and the
next turn-end re-reads the same lines and records the evidence exactly
once.

Trail finding: 019ff775-9965-7d46-a6f1-46500d66446f

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 01KZW6VK9T0K5CY9D5T5ABZK73
@peyton-alt peyton-alt changed the title feat(binding): record cross-repo session evidence (activity-time binding, slice 1) feat(binding): bind, adopt, and checkpoint sessions across repositories Aug 24, 2026
@peyton-alt
peyton-alt force-pushed the feat/session-binding branch from e3866e4 to 393e776 Compare August 29, 2026 00:40
@peyton-alt
peyton-alt changed the base branch from main to feat/global-trust August 29, 2026 00:40
@peyton-alt
peyton-alt force-pushed the feat/session-binding branch from 393e776 to 5f40167 Compare August 29, 2026 02:14
peyton-alt and others added 4 commits September 11, 2026 15:40
…y, token attribution

Review findings on trail 1018 (code-reviewer, silent-failure-hunter,
pr-test-analyzer) plus the open trail threads, verified against the code:

Adoption
- A linked worktree of the launching clone is never an adoption target:
  session state lives in the git common dir, so the "target store" was the
  source's own store — the fast path read the source state as a replica and
  the replay child ran the session's hooks in a worktree it did not belong to.
- The adopted marker is a hint, not the authority: the existence check now
  runs under the target's session-state lock for marked and unmarked repos
  alike, closing the unlocked-check → deleted-state → "already adopted" race.
- The 2s lock timeout bounds only the lock acquire. The state build ran under
  it and read git status through StatusWithBudget, whose breach latch is
  process-wide — a slow target put the launching repo's own capture into
  degraded mode for the rest of the hook. The build gets an uncancellable ctx;
  a missing untracked baseline is logged instead of silently dropped.
- MarkRepoAdopted failure (record rewritten concurrently, lock timeout) no
  longer withholds the replay: the replica exists, which is what the answer
  stands for; the failure is logged.
- runRevParse cleans the resolved root as it already did the common dir.

Replay
- Each replayed hook child is bounded (30s, WaitDelay 2s); its stderr is
  captured (capped) into the error; failures log at Warn — a failed replay is
  a checkpoint the target repo silently missed.
- A replay child without a pre-prompt baseline credits the turn only with
  transcript-evidenced files: the git-status fallback for Modified is withheld
  and New/Deleted are narrowed, so the target's pre-existing dirty edits and
  deletions are not attributed to the session.
- Tokens: a turn attributed to another repo's checkpoint still accumulates
  into this repo's session-wide total (StepContext.TokensAttributedElsewhere);
  only the checkpoint delta skips it. Zeroing the whole step under-reported
  `entire status` for every non-primary repo.

Logging
- The no-repo hook path attaches logging.Discard(): with no file sink,
  Info/Warn from shared code fell through to slog's default handler — the
  agent's stderr. In-repo binding failures are Warn, not Debug.

Tests: adoption into a globally tracked repo with the tier-off / excluded /
vetoed converses, sibling worktree not adopted, marker failure still
replicates, session total vs checkpoint delta, capped buffer, and the
formerly evidence-only tests now commit in the target and assert adoption
(an unborn HEAD made adoption fail silently while they passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 8940eac)
…s, isolated status budget

Findings from the self-review of the previous commit, fixed before pushing:

- Tokens: an attributed-elsewhere turn also excluded only the main-agent
  delta; the cumulative SubagentTokens rescope ran unconditionally and wrote
  that turn's subagent growth into the non-primary repo's checkpoint (and a
  later primary turn re-absorbed it). The growth is now folded into
  SubagentTokensBaseline and the rescope is skipped on those turns. Test
  covers a primary / elsewhere / primary sequence.
- Replay children never recorded deletions: Deleted was narrowed to
  transcript-evidenced paths, and transcripts never name deletions. The
  replica now snapshots the target's already-dirty tracked paths at adoption
  (State.DirtyTrackedFilesAtStart, from the same status walk as the untracked
  baseline) and the replayed turn-end subtracts that baseline from
  Modified/Deleted instead — the user's pending edit stays out, the agent's
  deletion stays in. Test: TestReplayedTurnEnd_UsesReplicaDirtyBaseline...
- Adoption ran its state build under context.WithoutCancel, which bounded the
  target's status walk only by the 20s StatusWalkBudget while holding the
  target's session-state lock and ignoring the hook deadline and Ctrl-C. New
  gitrepo.StatusWithIsolatedBudget (own budget, never arms the process-wide
  latch) with a 5s budget, and the work runs under the hook's own ctx.
- binding/resolve: Clean after the emptiness guard (Clean("") is ".").
- Replay: a cancelled parent is reported as cancellation, not as a 30s timeout.
- Docs: replay rules and known limits (token loss on primary-replay failure,
  per-turn lock cost).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5f40167)
…ngTurnPrimary

Every bindingTurnCollector method already returns early on a nil receiver, so
lifecycle callers never guard for nil; document that on the type and pin it
with a test that drives selectBindingTurnPrimary and every method through a
nil collector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit f1af640)
…ngTurnPrimary

Behavior-preserving: a nil collector already behaved as an empty one through
the nil-safe methods; an explicit empty collector says so at the call sites.
Also documents why an empty DirtyTrackedFilesAtStart means nothing was dirty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ebabc32)
peyton-alt and others added 7 commits September 11, 2026 15:40
…option on a failed baseline walk

Second pre-push review round on the replica baseline:

- The dirty-tracked baseline was snapshotted once at adoption and never
  refreshed, while a replay child never gets a pre-prompt baseline (only
  TurnEnd is replayed) — so anything the user edited in the bound repo AFTER
  adoption was credited to the agent's next turn. Every replayed turn now
  rewrites the baselines to the post-turn tree (alongside the existing
  untracked repair), so the next turn measures against the tree this one left.
- Deleted and dirty paths are tracked apart (DeletedTrackedFilesAtStart): a
  file that was merely dirty at the baseline and is deleted by the agent is a
  deletion, not something to subtract.
- A failed baseline walk (budget, cancellation, or the process-wide latch
  already armed by the launching repo's own walk) no longer persists a replica
  with empty baselines — that would credit every pre-existing change to the
  session for the rest of it. Adoption returns an error and the next turn
  retries; the adopted marker was already written only after success.
- Subagent-token baseline folding happens only for a step that carried a
  snapshot (a nil one cannot have grown the cumulative).
- Replay: a child that exited on its own is reported with its stderr even if
  the parent was cancelled meanwhile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 0c1c187)
…ed adoption without the turn's own evidence

Third pre-push review round:

- The per-turn baseline rewrite stored the FULL post-turn dirty/deleted sets,
  including the files this turn was just credited with. The baseline stores
  paths, so a later change to such a file that no transcript names (sed in
  Bash, a formatter) was subtracted as "still dirty from the user" and — the
  shadow tree is built from the credited list — its content never reached the
  checkpoint. The next baseline is now post-turn dirty/deleted MINUS this
  turn's credited files; a skipped or failed SaveStep then re-credits them
  next turn instead of losing them. The rewrite is also skipped when nothing
  changed (ErrMutationSkip).
- Adoption runs from the turn that produced the evidence, so the target's
  tree already holds that turn's agent work; the evidenced paths are excluded
  from the seeded baselines so the replay of that same turn does not subtract
  the agent's own deletions or new files.
- cloneAdoptSourceState clones the two new slices; test nits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b202968)
…cked baseline whole

Fourth pre-push review round:

- The refreshed tracked baselines are sorted before the unchanged-set check
  and the store: git status is a map, so without a canonical order the skip
  never fired past one dirty file and the persisted list churned each turn.
- The untracked baseline is rewind's preserve-list, and the replay child
  narrows new files by transcript evidence (and prunes created ones) itself,
  so it is seeded whole again: evidence cannot tell "the agent created this"
  from "the agent edited the user's untracked file", and dropping the latter
  would let a later rewind delete it if the replay never ran. Only the two
  tracked baselines exclude the adopting turn's evidence.
- Removed an unreachable ErrMutationSkip guard (MutateSessionState maps it to
  nil); tests cover the sorted compare and the tracked-seed exclusion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b08e992)
Five conflicts, all resolved by keeping both sides:

- strategy/strategy.go and lifecycle.go: the branch's TokensAttributedElsewhere
  and main's SubagentLedgerVersion are independent StepContext fields. Kept
  both; main's drop of MetadataDirAbs is preserved.
- strategy/session_state.go: the branch made the session flock context-aware,
  main made it rooted. Main already has flock.AcquireContextIn, which is both.
- strategy/manual_commit_git.go: both guarded the same subagent-delta block;
  both guards apply.
- redact/betterleaks_env_test.go: both quoted the repo path, main with %q.
  Took main's.

Notably absent: hook_registry.go and setup.go, which conflict on the
global-stacked lineage. This branch carries no global-enable code, so they
merge clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29A65K8NK74V74ARVA8HP7P
…1959)

The session record lives under the user config directory, outside any repo, and
recordPath built that path with filepath.Join and created it with a plain
os.MkdirAll. A pre-planted symlink at <config>/sessions therefore redirected
both the create and the write, which is finding #1959 on this PR.

Route it through a root instead: sessionsRoot opens the user config dir and
creates 'sessions' with osroot.MkdirAllNoSymlink, then takes it as a checked
child via osroot.SharedChild; reads go through osroot.ReadFileNoFollow so a
planted link at the leaf is refused rather than followed.

Carried across from the global-stacked lineage, where it was applied inside a
merge commit (c334e55) rather than as its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29A6N7S8C502PE33344KMBA
Carried the second half of the #1959 hardening (userdirs.ConfigDirChecked, from
d18ad7a) so record.go no longer calls userdirs.Config() unchecked, which the
userdirs consumer guard requires.

Audited binding's git-metadata traversals in gitrepo's guard: resolve.go's
rev-parse --git-common-dir/--show-toplevel (binding discovers clone identity
and the owning worktree from arbitrary evidence paths) and binding_tap.go's
.git-entry inspection (nested repository boundaries).

Dropped MetadataDirAbs from a strategy test; main removed that StepContext field.

Removed TestRecordForeignEvidence_GloballyTrackedRepoIsAdopted: it asserts that
a repo with no .entire is adopted because the user-global tier covers it. This
branch has no global tier -- IsSetUpAtRoot only looks for .entire files -- so
the test asserts a capability that does not exist here. It belongs on the
global-enable branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29AGFZVA913QRN81RT9SWPR
ensureSessionReplicated re-checked the target repo immediately before writing to
it, with a comment saying this kept 'a concurrent explicit disable an absolute
veto'. It did not. The check was IsSetUpAtRoot, which only Lstats for a settings
file — and `entire disable` sets enabled:false and leaves the file in place, so
a disabled repo still answered true. The veto never fired.

The effect: an agent touching a repository the user had turned Entire off in
still got the session replicated into it, writing session state under that
repo's .git and checkpointing into it.

Add settings.IsEnabledAtRoot — the at-root sibling of IsSetUpAndEnabled — and
use it at both binding call sites. It loads that root's merged settings and
honors enabled:false, failing closed on a read error, so an unparseable repo is
not one we write into either.

TestRecordForeignEvidence_DisabledRepoIsNotAdopted pins all three halves of the
old behavior: a disabled repo was recorded Enabled, adopted, and given
replicated session state.

This is pre-existing on the off-main lineage, not introduced by the restore. The
global lineage lost it incidentally in ceacbb7 by switching to the repository
policy classifier; when that classifier is available here it should supersede
this helper, since it also honors the user-global tier and its exclude lists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29BJBSC9GN8T3REPGV4ZS9W
@peyton-alt
peyton-alt changed the base branch from feat/global-trust to main September 11, 2026 23:48
peyton-alt and others added 3 commits September 11, 2026 19:10
The machine-level record store grew without bound. Every session with a
turn-end writes a record under the user config directory, including one
that never touches a repo — the no-repo scan advances its cursor on a
successful scan to keep repeat scans cheap, so a chatty no-repo session
leaves a cursor-only record behind. Nothing removed them: they live
outside every repo, so neither `entire clean` nor the strategy's cleanup
reaches them.

PruneStaleRecords removes a record whose UpdatedAt is more than
RecordRetention (30 days) old, along with the lock file mutateRecord
keeps beside it — leaving the locks would let the directory grow at
exactly the same rate. The window is deliberately generous against the
failure that would hurt: a record removed under a live session loses its
cross-repo evidence and rescans its transcript from zero, and sessions
run for days. A file that will not parse as a record is left where it is;
this directory is in the user's config dir.

It runs in the detached session sweep, and nominates that sweep itself.
The sweep is spawned when a session-state file looks like a zombie, and
the machines whose store grows are exactly the ones with no zombies to
find — so RetentionDue nominates a spawn once a day. The marker is
written by the prune rather than by the check, so a nomination whose
sweep never runs does not consume the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29P34ZAYQ04B41Y3NTBY1KQ
Binding asks "does Entire capture sessions here?" about a repository the
agent merely touched. That question has one right name in this codebase —
settings.IsActiveAtRoot, the spelling the forbidigo rule already points
callers at — and the helper answering it was called IsEnabledAtRoot, which
is a different and narrower question.

The rename is not cosmetic. The full predicate is "the repo's own settings
enable it OR global tracking covers it", and the second half needs the
repository-policy classifier, which is not on this branch: there is no
user-global tier here for it to consult, so the repo-level answer is the
whole answer until there is one. Taking the final name now means the
classifier-backed body replaces this one when the branches meet and no call
site moves — the alternative was every binding call site changing at merge
time, in a conflict.

IsSetUpAtRoot becomes unexported with it. Presence is the first half of the
predicate without its qualifier, never an answer a caller wants, which is
what the forbidigo rule says; nothing outside the package used it.

Documented at the helper: when the classifier arrives it also honors the
tier and its exclude lists, and it resolves the repository first, so a root
that is not a git repository answers false instead of being judged on stray
settings files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29QS8S3T9XAJYAX554Z1XTQ
The activation gate's two halves are each unit-tested, one on this branch
and one with the classifier, and nothing covers them composed: that binding
actually carries a repo the user-global tier alone makes active. That is the
half no settings file can express — a globally tracked repo has no .entire,
so a gate that reads settings files answers false for every repo the user
asked to have tracked machine-wide, and nothing visibly breaks. Evidence is
still recorded, merely as not enabled, and no session state is ever
replicated.

The classifier is not on this branch and neither landing order is decided,
so the test probes for the capability instead of assuming it: it asks
IsActiveAtRoot about a repository with no .entire while the tier is on,
which only the classifier-backed implementation can answer true. Inert
until the halves meet, running for real the moment they do, with nobody
having to remember it.

Verified both ways rather than assumed: on this branch both tests skip; on
a throwaway merge with the classifier present (never committed) both run
and pass, and reverting the gate to a settings-file presence check fails
them.

loadTargetState takes the session ID it loads instead of hardcoding one, so
these tests can use their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 01M29RJ42KSZ1M1M6XYW86DKSK
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants