@adlc/context-handoff

中文文档
1.12.0发布于 2026/10/10MITES Module构建来源已验证近 30 天下载 8564

Context-rot handoff — absolute band signals, session-terminal deny (D1–D3), and operator CLI for write/resume/bypass/repair/unlock (F3 continuity).

原始使用文档
文档目录3 节
  1. Continuation
  2. Supervision
  3. Deny-store expectation

@adlc/context-handoff

ADLC phase: P4 continuity (F3) — absolute context bands, session-terminal mutation deny (D1–D3), and the operator CLI for write/resume/bypass/repair/unlock/continue. Binding design: docs/specs/context-rot-handoff.md.

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.

adlc handoff write --session <id> [--ticket <id>] [--write] [--json]
adlc handoff resume --session <consumer> --deny-session <denier> [--write]
adlc handoff bypass --session <id> [--unbound-reason <text>] [--write]
adlc handoff repair --session <id> --ticket <id> --content-hash <h> [--write]
adlc handoff unlock --session <id> --pid <n> --started-at <iso> --host <h> --nonce <n> [--write]
adlc handoff continue --deny-session <denier> [--session <new>] [--capture-from <transcript>] [--write]
adlc handoff supervise [--dir .adlc] -- <command> [args...]

Mutating --write requires ADLC_MANIFEST_KEY (never silent success).

--dir names the ledger directory and its final path segment must be .adlc: artifacts and manifest evidence share that tree, and any other name is refused rather than splitting them. repair binds a deny that already exists and is still open — it never creates one. unlock reclaims only a lock minted on this host, so a dead-looking PID from another machine cannot evict a live session. A bypass grant on stdout is scoped to the calling adapter invocation; the durable proof is the context-handoff-bypass manifest entry; an explicitly empty --unbound-reason is refused rather than degraded to a bound grant.

write, resume, and repair all read-modify-write a deny marker, so each holds that session's .adlc/handoffs/<id>.lock (O_EXCL, released on exit) and exits 2 when a live session on this host holds it. write rebinds the marker onto the final it writes — ensureDenyMarker is idempotent, so without that a refreshed hash would wedge every later resume — and refuses to unbind or to refresh a consumed deny. When the manifest append fails, the run's file mutations are rolled back so no bind survives that nothing attests.

Continuation

continue is the sanctioned recovery from a handoff deny: it captures the denied session, binds the final to that capture, and consumes the deny for ONE successor. The denier is never un-denied — D2 stays sticky and the work moves to a new session. It composes capture → write → resume in a single run under the denier's lock, records context-handoff-continue, and rolls back every file it touched when that evidence append fails.

The capture body lives at .adlc/handoffs/content/<session_id>.md and is written only by host-privileged code; isProtectedHandoffPath denies agent writes to .adlc/handoffs/content/**, and continue joins the mutating subcommands an agent's shell must not run under deny. content_hash is sha256 over the canonicalized capture body (LF line endings, no trailing whitespace), so re-deriving it from disk catches an edited capture that a valid signature cannot.

Degrades with exit 2 and nothing consumed: an unbound deny (bind it with repair first), a consumed deny, a missing or corrupt --capture-from source, a successor id that already holds a resume-auth, an id that cannot be safely quoted in the prompt, or an active ticket that disagrees with the deny's bind. The successor id comes from --session or is minted by the command — never from agent input.

A --capture-from transcript older than HANDOFF_MAX_AGE_HOURS contributes no model narrative: the brief still ships, with the omission stated in it. Age is read from the transcript's own newest timestamp, falling back to the file mtime only when no entry carries one.

The bind is enforced, not advisory. A capture-backed record carries content_kind: 'capture' on both the final and the deny marker. Every enforcing adapter re-derives the capture's sha256 from disk on each mutation check, so an edited, oversized, or deleted capture denies with a capture_tamper:<session> reason — including for the successor that already consumed the deny. It is plain sha256, so a keyless hook enforces exactly what the keyed CLI does. Records written before this field exist keep their previous semantics untouched: their hash was never re-derivable, so there is nothing to check. repair clears content_kind when it rebinds a hash, which is the documented way out of a lost capture.

Reading a capture back. Use readVerifiedCapture(root, sessionId, expectedHash). It returns the body only when the bytes on disk still hash to the value the deny record and resume-auth are bound to; missing, oversize, and altered all fail closed with no body. Supervisors and session-start injectors must go through it rather than reading the file — a signature proves the hash was authorized, never that the file still matches it.

A keyless injector (a harness hook, which scrubs the manifest key before it imports anything) gets the content bind from this function and nothing more: it cannot verify the resume-auth's HMAC, so it must surface a capture as advisory context and say so, never as proof that the reading session is an authorized continuation. Authorization is decided where a key exists — the supervisor, and the mutation gate that re-derives the same hash on every mutation.

import { readVerifiedCapture } from '@adlc/context-handoff/lib/capture.mjs';

const got = readVerifiedCapture(root, denySessionId, denyRecord.content_hash);
if (!got.ok) return; // absent, oversize, or edited — inject nothing

Capture content is redacted. Credentials are stripped as part of composing the brief, not as a step a caller remembers — the capture is persisted AND pasted into the successor's prompt on one path. The shapes mirror the findings ledger's (packages/core/lib/ledger.mjs), which refuses to commit a finding containing one; test/capture-redact.test.mjs pins the two behaviourally. Removed spans leave an explicit [adlc: redacted <kind>] so a reader knows. Pure-hex runs are exempt at any length — a brief's job includes quoting the sha256 content_hash the successor verifies against. Best-effort, not a proof of secret-freedom.

One successor id, one authorization. The resume-auth is created with O_EXCL, so two continuations of different denies naming the same successor cannot both believe they authorized it — the denier's lock cannot serialize that, since they hold different locks. The loser degrades with exit 2 and the winner's grant is untouched.

Rollback never overwrites a stranger. Every undo is a compare-and-swap on the bytes this run wrote: an artifact another writer has since taken is left alone and named in the error, because the failure that triggers a rollback is often that writer.

Capture content is fenced. Everything the brief carries is attacker-reachable — a branch name, a filename in git status, a ticket title, the previous session's own words — so each section is wrapped in <<<UNTRUSTED-CAPTURE-DATA / END-UNTRUSTED>>> markers, with those delimiters stripped from the content so the fence cannot be closed from inside. bootstrap_prompt repeats, before and after the body, that fenced content is recorded data rather than instructions.

Supervision

supervise is the zero-touch path: it wraps a harness command, and when that session hits a handoff deny it performs the whole recovery the operator would otherwise perform by hand.

ADLC_MANIFEST_KEY=$KEY adlc handoff supervise -- claude --model opus

It mints the first session id, polls that session's deny marker (~2 s; fs.watch is not trusted), waits for the transcript to stop growing (5 s of stability, or the child exiting) so the handoff summary is captured whole, runs handoff continue --write, terminates the superseded child (SIGTERM, then SIGKILL after 10 s), and respawns the harness with the successor id and the bootstrap prompt. Successor ids come from continue, never from the wrapper.

The key stays with the supervisor. ADLC_MANIFEST_KEY is required up front and reaches only the continue step; superviseChildEnv strips it — along with ADLC_ADMIN_KEY — from every harness child.

Contract item 24. Every spawn also drops CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_SESSION_ID and CLAUDE_CODE_ENTRYPOINT. A claude process that inherits them treats itself as a nested child and silently stops writing its transcript — no error, no warning, just a continuation with nothing to capture. The scrub is applied per spawn, not once at startup.

Degrade hands the session back. An unbound deny, a corrupt transcript, a payload that cannot be trusted, or a capture that no longer matches its content_hash all stop the loop with exit 2, a single warning, and the copy-pasteable handoff continue one-liner. The child is left running and nothing is consumed — a degrade is a decision for the operator, and killing their session first would take it away from them.

A failing harness is a failing supervisor. The wrapper is what a script waits on, so it never reports its own success for a session that failed: exit 3 when the supervised command exits non-zero or is killed, exit 4 when it could not be started at all. The harness's own code is carried in the message and the JSON payload rather than passed through, because 1 and 2 already mean something about the supervision itself. A clean exit and an operator's Ctrl-C are both 0.

A crashed intermediate is reported, not failed. A session that writes its deny and then dies on its own — non-zero, or a signal the supervisor never sends — still hands off: the deny already said it was being replaced, and the supervisor terminates it on the ordinary path anyway, so failing the run would fail every successful handoff. It is named on stderr and carried in the JSON as abnormalExits, because a handoff written by a session that crashed came from different circumstances than one that stopped when asked.

A missing transcript is not a failed continuation. The narrative is optional — the deterministic brief still carries ticket, evidence and git state — so a session that dies before its transcript appears is continued without one rather than degraded. It is reported loudly all the same, because a missing transcript is what the item-24 environment scrub failing looks like from the outside.

The capture is re-verified at the injection point. The mutation gate already re-derives the hash on every evaluation, but that defends mutation; the bootstrap prompt is read by a model before it touches a tool, so the supervisor calls readVerifiedCapture again before spawning the successor.

import { WARN_PCT, HANDOFF_PCT, HARD_PCT } from '@adlc/context-handoff/lib/thresholds.mjs';
import { evaluateBands } from '@adlc/context-handoff/lib/bands.mjs';
import { evaluateMutationGate } from '@adlc/context-handoff/lib/mutation-gate.mjs';
node --test packages/context-handoff/test/*.test.mjs

Deny-store expectation

loadDenyRecords treats a missing denies/ as unavailable only when .adlc/.deny-store exists (JSON {schema,sessions} written by ensureDenyMarker after a verified marker; sessions[] makes selective marker delete fail closed). The sentinel is a sibling of handoffs/ so deleting handoffs/ alone cannot clear expectation; full signed per-deny ledger is still deferred. Ticket-store presence alone does not expect denies. A legacy .adlc/handoffs/.deny-store is treated as expected and self-healed to the new path. evaluateMarkerOnReentry does not use the global sentinel for per-session marker_vanished — callers thread denyEverWritten. Unbound operator bypass may clear D0:deny_store_unavailable and D3:invalid_record.

Advisory nags (nagSuppression) are suppressed when remaining-to-hard is below MIN_REMAINING_TO_HARD (near-hard / handoff zone) so deny/handoff owns the signal; this never affects mutation deny.

版本与发布记录

@adlc/context-handoff 共发布 6 个版本,首次发布于 2026/08/08;过去 12 个月发布了 6 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.12.0latest2026/10/1027 天前
1.11.12026/09/091 个月前
1.11.02026/08/252 个月前
1.10.02026/08/112 个月前
1.9.02026/08/092 个月前
1.8.02026/08/082 个月前

关于 @adlc/context-handoff 的常见问题

@adlc/context-handoff 是什么?

Context-rot handoff — absolute band signals, session-terminal deny (D1–D3), and operator CLI for write/resume/bypass/repair/unlock (F3 continuity).

如何安装 @adlc/context-handoff?

在项目目录运行 npm install @adlc/context-handoff,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 handoff,可以用 npx handoff 直接运行。

@adlc/context-handoff 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/adlc__context-handoff 是否存在。

@adlc/context-handoff 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@adlc/context-handoff 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@adlc/context-handoff 可以商用吗?

@adlc/context-handoff 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@adlc/context-handoff 的下载量有多少?

根据 npm 官方下载统计,@adlc/context-handoff 在 2026/10/05 – 2026/11/03 这 30 天内被下载 8,564 次,日均约 285 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@adlc/context-handoff 还在维护吗?

最新版本 1.12.0 发布于 2026/10/10(27 天前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/08/08。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。