context-handoff
Absolute context-band signals, session-terminal mutation deny (D1–D3), and the operator CLI for write/resume/bypass/repair/unlock — F3 continuity handoff.
context-handoff
ADLC phase: P4 continuity (F3) · Library + CLI: absolute context bands, session-terminal mutation deny (D1–D3), and operator handoff commands.
Status since 1.11.1 (#966): no harness evaluates the session-terminal deny (D1-D3) by default. The Claude Code and Codex plugins ship without the hook entry; pi and OpenCode run the gate only with ADLC_CONTEXT_ROT_HANDOFF_ENABLED=1. The band thresholds are still consumed by build-gate, and the adlc handoff CLI is unaffected.
What it defends against
Long sessions rot: depth, transcript size, and context-window fill climb past the
point where the agent still holds the plot. context-handoff freezes the
absolute band thresholds and the session-terminal deny contracts that
later harness adapters and build-gate will share — so no local copy of the
numbers can drift, and a deny written once cannot silently clear when a marker
file disappears.
Slice 2 adds the operator/host CLI (adlc handoff …) that adapters and
humans call to write finals, resume/consume denies, bypass, repair binds, and
unlock. Harness adapters and build-gate migration land in later slices.
Absolute bands
Signals are OR-joined across available kinds (pct, depth, bytes). Missing
kinds are ignored; present-but-malformed or out-of-domain values fail closed as
hard. Floor is never subtracted from band comparators (no floor-delta bands).
| Band | pct | depth | bytes |
|---|---|---|---|
| warn | ≥ 50 | ≥ 20 | ≥ 128 KiB |
| handoff | ≥ 60 | ≥ 30 | ≥ 192 KiB |
| hard | ≥ 80 | ≥ 40 | ≥ 256 KiB |
Advisory nags (nagSuppression) may be suppressed near hard so deny/handoff
owns the signal; that path never affects mutation deny.
Deny lifecycle (D1–D3)
| Code | Meaning |
|---|---|
| D1 | Deny active for the denier session — mutations blocked. |
| D2 | Deny sticky across re-entry for that session until a verified consume. |
| D3 | Other open denies in the store also block mutations for non-denier sessions. |
After a verified other-session consume, that record leaves D3 (status=consumed);
the denier remains D2. Marker vanish / store-unavailable paths fail closed unless
an unbound operator bypass clears the recoverable codes.
CLI (adlc handoff)
Dry-run by default. Pass --write to mutate. Mutating commands require
ADLC_MANIFEST_KEY and append durable evidence to .adlc/manifest.jsonl —
missing key exits 1 (never silent success).
adlc handoff write --session <id> [--ticket <id>] [--host <h>] [--content-hash <h>] [--write] [--json]
adlc handoff resume --session <consumer> --deny-session <denier> [--write] [--json]
adlc handoff bypass --session <id> [--unbound-reason <text>] [--write] [--json]
adlc handoff repair --session <id> --ticket <id> --content-hash <h> [--write] [--json]
adlc handoff unlock --session <id> --pid <n> --started-at <iso> --host <h> --nonce <n> [--write] [--json]| Command | Role |
|---|---|
| write | Final checkpoint + ensure deny marker / .adlc/.deny-store |
| resume | Other-session consume (same-session exits 2); writes resume-auth cache |
| bypass | Bound (session only) vs unbound (--unbound-reason) one-shot grant |
| repair | Privileged host bind of an existing open deny ticket_id+content_hash |
| unlock | Reclaim lock only when the PID is dead, the lock is this host's, and all fields match |
repair never creates a deny: a missing or already-consumed marker exits 1,
so the command that relaxes a deny cannot arm one. resume makes the manifest
entry durable before it flips the record (outcome: authorized attests the
authorization, not the persisted consume), and rolls the resume-auth cache back
if anything fails — a failed resume leaves the deny open. The bypass grant
printed on stdout is scoped to the calling adapter invocation; the durable
proof is the context-handoff-bypass manifest entry. An explicitly empty
--unbound-reason exits 1 rather than handing back the bound grant that cannot
clear what the operator asked to override.
bypass --write exits 1 when the resolved ledger has neither a .adlc/handoffs directory nor a surviving .adlc/.deny-store sentinel (the D0 state, repairable only by an unbound bypass) — run from the repo root (an operator outside the hook may instead pass --dir <repo>/.adlc; the hook's recovery grammar does not accept --dir) — so a grant is never reported as recorded under a tree the enforcing hook does not read; a dry run prints the resolved ledger path.
write is the refresh path, so it rebinds an open marker onto the final it
writes; a refresh that would unbind one (dropping --ticket) or refresh a
consumed one exits 1 instead of leaving a final and a deny that disagree. Every
command that mutates a marker — write, resume, repair — runs under that
session's .lock and exits 2 when a live session on this host holds it, so one
deny authorizes exactly one successor. If evidence cannot be appended, the
files written in that run are rolled back: un-evidenced state is un-auditable
state. A hard-killed process leaves its lock behind for unlock to reclaim.
Artifact paths (under the repo root; --dir names the .adlc directory, and
its final path segment must be .adlc so artifacts and evidence stay together):
- Final:
.adlc/handoffs/finals/<session_id>.json - Deny:
.adlc/handoffs/denies/<session_id>.json+.adlc/.deny-store - Resume-auth:
.adlc/handoffs/<session_id>.resume-auth.json - Lock:
.adlc/handoffs/<session_id>.lock
Library usage
import { WARN_PCT, HANDOFF_PCT, HARD_PCT } from '@adlc/context-handoff/lib/thresholds.mjs';
import { evaluateBands, nagSuppression } from '@adlc/context-handoff/lib/bands.mjs';
import { evaluateMutationGate } from '@adlc/context-handoff/lib/mutation-gate.mjs';
import { ensureDenyMarker, loadDenyRecords } from '@adlc/context-handoff/lib/deny-marker.mjs';
const bands = evaluateBands({ pct: 65, depth: 12 });
// → { warn: true, handoff: true, hard: false }node --test packages/context-handoff/test/*.test.mjsBinding design:
docs/specs/context-rot-handoff.md
and the package README.