Skip to content

feat(doctor): give "is this session safe to end" a verb, so it stops being estimated - #840

Merged
wenzowski merged 11 commits into
mainfrom
claude/cloud-1376-session-drain
Sep 3, 2026
Merged

feat(doctor): give "is this session safe to end" a verb, so it stops being estimated#840
wenzowski merged 11 commits into
mainfrom
claude/cloud-1376-session-drain

Conversation

@wenzowski

@wenzowski wenzowski commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Closes CLOUD-1376

DO-NOT-CLOSE CLOUD-1079

44dba857 serves CLOUD-1079 and is the opposite of completing it. That commit restores the two declared exemptions because the launcher still re-provisions stop-hook-git-check.sh and session-start-git-identity.sh at session start — measured with mtime 00:55, after dda531f7 landed and after session:wiring reclaimed them. The rows exist to name a live defect; CLOUD-1079 owns removing the cause. Closing it here would record as fixed the exact thing this branch measured as still broken.

The defect, measured

Asked "Done? Safe to archive?", this repository's own agent enumerated the working tree, git stash, local branches and running processes, found all four clean, and answered "yes — safe".

At that moment /root/.claude/tasks/<session>/21.json read "status": "pending". The claim was false when it was made, and a file on disk could have decided it. Worse, the same message disclosed the counter-evidence — it said the finding was unfiled and offered to file it "before you archive" — and did not let that change the verdict. (It was also wrong about "unfiled": CLOUD-1304 already existed.)

Root cause: a completion question with no verb

Every other completion question resolves to a command. verify decides the tree, land the PR, done-check the release, claim-check the pull. Nothing decided the session. Non-negotiable rule 3 says gates decide and never estimate — but a rule needs an instance to bind to, so the one completion claim with no command behind it is the one that was wrong.

The prose reinforced the scope error. AGENTS.md's "committed-and-pushed is the only state that survives a VM reclaim" is true of the tree and was read as the whole question — helped by the only end-of-session signal that speaks in this container being stop-hook-git-check.sh, which nags about unpushed commits and nothing else.

Why no landed Stop rule caught it — measured, one at a time

stop_nudges runs five ranked rules and batten is registered on Stop, so the surface fired.

rule why silent
1 stop-posture no hedged flag — the claim was flat and confident
2 finding-sink-check needs a path:line citation ∧ no durable write. Run over the session transcript: exit 0. The turn cited branch names and line counts, no coordinate
3 filed-here no branch in flight; the PR had merged
4 unlanded-check needs a completion marker ∧ no commit on target. The tree was landed — correctly and uselessly silent
5 spun-off set already answered

Rule 2 is the near miss, and its narrowing was correct on its own evidence (over 113 turns the citation shape fired 1/1 true, the exit-claim shape 2/2 false). It never sampled this class, which is the inverse of what it hunts: not "I found something and wrote it nowhere" but "I asserted there is nothing left to find."

What lands

batten doctor session0 nothing open, 1 open work with ids, 3 could-not-look.

Could-not-look is 3 and never 0, and that single mapping is the deliverable. The defect is an absent reading reported as a clean one, so the arm with nothing to read must not share a code with the arm that read and found nothing. An undeclared template, an unreadable store, and one malformed member all take it — a partial count is a number that looks measured and is not.

ExitCode::Violation stays unreachable, for WiringReport::code's reason: a doctor sub-verb is a diagnosis, a mediating harness reads 2 as a deny, and "you have unfinished work" is not "policy says no". The issue's Ready block said 2; the code table corrected it.

Rule 1 decides where the layout lives. The store sits outside the repository root and its shape is the host's, so deriving it in crates/batten would put a directory layout in the core. The consumer declares a template carrying {session}; the engine substitutes the one field the envelope already normalises across hosts.

It joins [transcript] rather than opening a [session] table, on that table's own stated reasoning: the transcript's format and the host's memory layout are already "two facts about one host", and splitting them "would be the widening rule 6 forbids".

Verification

The acceptance clause, end to end. Against a copy of this session's own store with 21.json restored to its 22:35 state, and against the live store:

doctor session: 1 of 21 declared task(s) open — 21          exit 1
doctor session: 0 of 21 declared task(s) open               exit 0

The first line is the answer that was given as "yes — safe".

Shown able to fail, by hand, twice (CLOUD-418) — a suite of absence assertions is exactly where a green means nothing:

  • None => ExitCode::Success (could-not-look reported as clean — the original defect restated as code) reddens the three could-not-look arms and nothing else.
  • Some(0) => ExitCode::Usage (refuse unconditionally) reddens a_store_whose_tasks_are_all_completed_is_clean and nothing else — the anti-vacuity mirror doing the one job it exists for.

The pointer-only census refused the easy way out, and that is the better half. Adding the row was not enough: with no store in the corpus the verb answers could-not-look, and the sweep asserts code != Some(3) before it will read output at all — "failed internally, so what it did not emit proves nothing". MAY_ANSWER_COULD_NOT_LOOK would have silenced that and bought a census row that never exercised the verb. So the corpus seeds a real store whose task subject is a content canary: the verb reads an agent's prose on every sweep and must emit the id and counts without it.

Four corrections made in flight, recorded because each was believed first

  • The exit code for could-not-look was written into the issue as 2; doctor may not return 2 at all.
  • ~/ needed real expansion. Without it the declared template resolves to no directory — which reads identically to a consumer with no work: the same dead-declaration class this fix is about, nearly shipped inside it.
  • git add -A committed .claude/.tasks, a symlink naming this container's session. Every other checkout would inherit a dangling pointer, and doctor session reads a dangling link as could-not-look forever. Caught by a review bot's file list, not by me and not by a gate; .gitignore is now the mechanism.
  • tasks = "/nonexistent/{session}" inside a format! consumed {session} as a named argument. The compiler caught it here; the same template where no compiler looks is a placeholder that silently resolves to nothing.

Prose ships with the mechanism

Non-negotiable rule 2 — prose alone is half a change, and half a change is what failed. policy-budget refused three drafts (3566 tokens of 3500, then 204/201/200 lines of 199) because AGENTS.md sat exactly on its line cap, so the rule was paid for in words rather than appended.

Also carried: a base-branch fix, ported

44dba857 is not this PR's work. harness-wiring refuses origin/main itself — 2 findings on dda531f7, none on dda531f7^ — and it is ported here per the drive-to-green rule that an existing fix is ported rather than waited on. It no-ops once main carries its own. Full measurement in the earlier comment.

Not in scope

  • The Stop nudge (a sixth stop_nudges rule). Recorded in this branch's plan store as stop-nudge-rule-six deleted — a withdrawal on the record rather than quietly missing. A nudge fires after the turn in which the question is asked; the verb is the half that prevents the claim.
  • Refusal at Stop is architecturally unavailableEvent::carries_a_verdict is the one authority and Stop carries none (CLOUD-97, CLOUD-219). An acceptance clause promising it would be unmeetable, which is CLOUD-1314's shape.
  • CLOUD-1354's conjunction (clean ∧ pushed ∧ ahead ∧ no receipt) — the git half of end-of-session, where this is the declared-work half.

Filed and fixed from the losing side: the false claim was caught by the user asking how it could have been made, not by any mechanism.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VtDugY1mEeCLNA8ULMHocH

@linear-code

linear-code Bot commented Sep 3, 2026

Copy link
Copy Markdown
CLOUD-1376 "Safe to archive?" is a completion question with no verb, so it is answered by estimate — the session's own open-task file was on disk, said `pending`, and no gate reads it

Measured, 2026-09-02

The user asked "Done? Safe to archive?" The agent answered "Yes — safe", having enumerated the working tree, git stash, local branches and running processes, and found all four clean. That answer was false when it was given, and it was false in a way a file on disk could have decided.

At the moment of the claim, /root/.claude/tasks/<session>/21.json read:

{ "id": "21", "status": "pending",
  "subject": "7. Protected-path hook missed an interpreter write …" }

21.json flipped to completed at 22:40:51; the claim was made at roughly 22:35. So for the whole of that turn the session's own store held a typed, machine-readable answer of no, and nothing read it.

Worse than the omission: the same message that asserted safety also disclosed the thing that made it false — it said the finding was unfiled and offered to file it "before you archive". The agent stated the counter-evidence and did not let it change the verdict. (It was also wrong about "unfiled" — CLOUD-1304 already existed, one list_issues call away.)

Why every landed mechanism was silent — measured, one at a time

stop_nudges (crates/batten/src/lib.rs:8444) runs five ranked rules at Stop and emits at most one. Batten is registered on Stop for claude-code (.claude/settings.json), so the surface fired. Each rule was silent for its own correct reason:

rule predicate why silent here
1 stop-posture hedged-flag literals in final-message no hedged flag; the claim was flat and confident
2 finding-sink-check a path:line citation no durable write the citation conjunct. The turn cited stash@{0}, backup-before-admits, file names and line counts — no path:line. Run over this session's transcript: exit 0
3 filed-here overlap a row this branch filed names a file it changes no branch in flight; the PR had merged
4 unlanded-check a completion marker ∧ no equivalent commit on the landing target the tree was landed. The claim was about the session, not the tree, so this was correctly and uselessly silent
5 spun-off enumeration rows this branch spun off suppressed on the set; the set was already answered

Rule 2 is the near miss and is worth stating exactly, because it is the one written for this family: "a finding stated in prose with nothing durable written." Its shape was narrowed to a path:line citation on evidence — over a 113-turn transcript the citation shape fired 1/1 true, the exit-claim shape 2/2 false, the fenced shape 0/0. That narrowing was correct for the class it sampled. It simply never sampled this class, which is the inverse: not "I found something and wrote it nowhere" but "I asserted there is nothing left to find."

Root cause

1. The question had no verb, so it was answered by estimate. Non-negotiable rule 3 says gates decide and never estimate — but a rule needs an instance to bind to. verify decides the tree, land the PR, done-check the release, claim-check the pull. Nothing decides "is this session safe to end." So the one completion claim with no command behind it is the one that was wrong, and the doctrine that would have caught it ("the gates ARE your authorization") had nothing to authorize with.

2. Every batten completion predicate binds to an artifact that outlives the session — a tree, a branch, a commit, a PR. The session was treated as the one unit of work with no artifact. It has one: /root/.claude/tasks/<session-id>/*.json, one file per task, each with a typed status. It is outside the repository root, which is precisely what input.tree.external (CLOUD-1167) exists to reach.

3. The prose defines survival in git terms, and that is what scoped the answer. AGENTS.md:

committed-and-pushed is the only state that survives a VM reclaim

True of bytes, and read as the whole of the question. Reinforced by the environment: the only end-of-session signal that actually speaks here is the launcher's stop-hook-git-check.sh, which nags about unpushed commits and nothing else. The one recurring teacher of "what does end-of-session mean" asks a git question, so a git answer felt complete.

What to build

A sixth stop_nudges rule — the session's own declared-open work

Stop
  ∧ the session task store is readable        (could-not-look otherwise)
  ∧ count(tasks where status != "completed") > 0
  →  nudge: N task(s) open, ids listed

Every term is a file read and an integer comparison. No prose is scored, no model verdict is taken, and the output is a count plus task ids — pointer-only under non-negotiable rule 4. It is the same sibling-program shape rules 2 and 4 already use.

And a verb, which is the half that actually prevents the lie

The nudge fires at the end of a turn. The question arrives inside one. So the durable fix is a command the agent runs instead of forming an opinion — batten session drain (name TBD), exit 0 clean / 1 open work / 2 could-not-look — so that "safe to archive?" is answered by an exit code that can be quoted, exactly as verify answers "is this landable?".

Note on [drain]: that key is drain::DEFAULT_TOKEN_BUDGET, the token budget for the additionalContext channel. It is the pipe a nudge renders into, not a session-end predicate — a candidate name collision to avoid, not an existing home.

The bound that must be stated rather than designed around

A Stop **refusal is impossible. **Event::carries_a_verdict is the one authority and Stop carries none — ruled independently by CLOUD-97 and CLOUD-219, and preserved by construction. So no rule here can refuse the claim. The verb is what makes the answer checkable; the nudge only makes its absence loud. Any acceptance clause promising refusal at Stop would be unmeetable, which is CLOUD-1314's shape and must not be repeated here.

Prose changes this row owes

  • AGENTS.md's survival sentence gains the second half: committed-and-pushed is what survives of the tree, and the session's declared-open work is state too — it dies with the container unless it names a durable key.
  • A statement that a completeness claim about the session is a gate question, and is answered by running the verb rather than by enumerating git artifacts.

Per non-negotiable rule 2 the prose is feedforward only and ships with the mechanism above; prose alone would be half a change and is exactly what failed here.

Deliberately not in scope

  • Scoring whether a turn's prose "asserted completeness". Not decidable, and rule 3 forbids it. The object is the task store, never the sentence.
  • Refusing at Stop — architecturally unavailable, see above.
  • CLOUD-1354's conjunction (clean ∧ pushed ∧ ahead ∧ no receipt). That is the git half of end-of-session and this is the declared-work half; they share a surface and share no term.

Ready

  • **Authority boundary (§1). **stop_nudges in crates/batten/src/lib.rs plus a sibling program, and a new verb; compiled-binary tier in crates/batten/tests/it/.
  • Computable predicate (§2). The count above, at Stop, once per task-store revision.
  • Could-not-look (§2). No task store, an unreadable directory, or malformed JSON leaves the turn unjudged. A verdict about a missing reading is not a verdict about the session — and reading an absent store as "zero open" is the false clean this whole row is about.
  • **Effect (§3). **read.
  • Output and exit (§5). Pointer-only: a count and task ids, never a subject line or description. Advisory at Stop; a real exit code from the verb.
  • **Commit / bump (§6). **feat(policy) — patch until 0.1.0.
  • Test obligation (§7). Over the compiled binary. Shown able to fail, four discriminating observations: a store with one pending task nudges; a store with every task completed is silent — the anti-vacuity mirror, without which the arm is satisfied by a rule that nudges every turn; an absent store is could-not-look and not a pass; and the nudge does not repeat while the store is unchanged.
  • Blockers (§8). None. relatedTo CLOUD-1354 (the git half of the same surface), CLOUD-97 / CLOUD-219 / CLOUD-888 (why Stop carries no verdict), CLOUD-252 / CLOUD-248 (rule 2, whose measured narrowing this class falls outside), CLOUD-1167 (input.tree.external, the landed way to reach a path outside the root).

Acceptance

  • With one task open, ending a turn produces exactly one nudge naming the count and the ids.
  • With every task completed, it produces none.
  • With no readable store, it produces a could-not-look and never a clean.
  • The verb returns 1 for the measured 22:35 state and 0 for the 22:40:51 state.

Filed from the losing side, twice over: the false claim was caught by the user asking "how the fuck could you claim everything was safe to archive", and the missing row it concealed was caught by the user calling an offer-to-file a punt. Neither is a mechanism.

CLOUD-1079 Stop provisioning the two user-level git hooks into `~/.claude/launcher-settings.json` — the owner action CLOUD-605 named and nothing owns

Why

~/.claude/launcher-settings.json registers two programs this repository does not own and cannot unregister:

SessionStart: ~/.claude/session-start-git-identity.sh
Stop:         ~/.claude/stop-hook-git-check.sh

CLOUD-605 established that a delete does not survive, and named the remedy without owning it: "the only place it can actually be turned off is the environment configuration that generates launcher-settings.json — outside this repository, an owner action. That is the real remedy." This row is that action. It exists so the remedy has a home rather than being a sentence inside a Done issue.

Re-provisioning, measured 2026-08-27. A previous session ran batten wiring reclaim -y, which removed both registrations from the merged surface. This session's files carry identical mtimes of 22:36:38launcher-settings.json and both scripts, rewritten together at session start.

"So the repair and its erasure are one event, and no in-session action can outlast it." THAT INFERENCE IS FALSE AND IT IS THE WHOLE DEFECT IN THIS ROW. One manual invocation was erased by one re-provision, and the conclusion drawn was permanent impossibility. What was never tried is what this repository already does twice: register the repair as a session-start handler so it runs on every session, after the launcher provisions. session:signing exists for precisely this reason — "the launcher writes commit.gpgsign true --global every session, and local beats global only if something writes local" — and it wins. A repair that runs once loses to a rewrite that runs every session; a repair that also runs every session does not.

Re-measured 2026-09-02, and it sharpens the point rather than rescuing the old claim. Both scripts carry mtime 16:59 — rewritten MID-SESSION, not only at session start. So a one-off manual delete is even more hopeless than recorded, and a handler on the provisioning path is even more clearly the answer.

Why each should stop being provisioned, for this environment

stop-hook-git-check.sh is unsatisfiable here by construction. Its predicate (line 83) is an OR:

if [[ "$ce" != "noreply@anthropic.com" ]] ||
   ! git cat-file commit "$sha" | grep -qE '^gpgsig(-sha256)? '

Commits here are SSH-signed, so the second term is already satisfied and the email term alone carries the refusal. The only value it accepts is the one [attribution] identity_deny forbids, so the hook exits 2 on every correctly-attributed commit and the remedy it prints produces a commit commit-attribution refuses. Two policies, one of which has to not run.

session-start-git-identity.sh sets user.email, user.name and core.hooksPath globally (lines 21, 22, 104). The identity half is redundant here — attribution-identity writes repo-locally and local beats global, which is CLOUD-605's own recorded reason attribution in this repo has never failed.

The core.hooksPath line is new information and changes the shape of the ask. CLOUD-605 describes this script as an identity setter; it is also a global git-hooks redirect. So removing it wholesale has a blast radius beyond commit identity, and this row asks the owner to decide per line rather than per file. Turning off the identity pinning is what this repository needs; the hooks-path line may be load-bearing for the container and is not ours to judge.

What this blocks

The hook-registration campaign's remaining half. With these two present, hooks-wiring-check under a [hook] exclusive declaration can only be satisfied by deleting another tool's user-level configuration — which is the delete-and-lose race above. Once they stop being provisioned, the campaign's strong reading and the gate agree with no exemption and no delete, which is the only state where zero siblings is true rather than asserted.

Refinement — Ready

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). CORRECTED 2026-09-02. *"The environment configuration that generates ~*/.claude/launcher-settings.json — a console/provisioning surface outside every repository." False. The authority is batten.toml's [[hook.handler]] sequence plus mise.toml's session:* tasks — the same surface session:identity and session:signing already use to repair launcher-provisioned state. batten doctor hooks -J's merged_siblings remains the observation; batten wiring reclaim is the repair, and it existed before this row was filed.
  • **Computable predicate (§2). **batten doctor hooks -J reports merged_siblings: 0 for claude-code on a fresh container, with merged_surfaces_read non-zero so the census is known to have looked rather than failed to. Both numbers, because a zero from a surface nobody read is the false green this whole census exists to refuse.
  • Effect (§3). No change in this repository beyond the follow-up in §7. The action is a configuration change in the environment that provisions containers.
  • Generated artifacts (§4). None.
  • Output & exit (§5). Unchanged.
  • **Commit / bump (§6). **fix → patch until 0.1.0, for the follow-up that removes the two now-obsolete DECLARED rows. Not chore: release-plz gives chore no bump at all, and this is not housekeeping — deleting an exemption changes the gate's verdict on an input it previously excused.
  • Test obligation (§7). After the environment change: a fresh container reports merged_siblings: 0, and the two stop-hook-git-check.sh / session-start-git-identity.sh rows come out of hooks-wiring-check.sh's DECLARED table in one commit, with the gate green because the siblings are genuinely gone. Shown able to fail by re-adding a merged registration to a fixture $HOME, which tests/hooks-wiring-check.bats already drives through HOOKS_WIRING_MERGED.
  • Blockers (§8). None. "The action itself needs an owner with access to the provisioning configuration." RETRACTED 2026-09-02 — a punt, not a constraint, inherited by quotation from CLOUD-605, which is itself now retracted. The action is this repository's and is landing under this row.

Done

A fresh container carries no non-batten hook registration on any merged surface, batten doctor hooks reports merged_siblings: 0 having read the surfaces, and the two DECLARED rows are deleted rather than excused.

**Absorbed from **CLOUD-1086 2026-09-02 — what that row uniquely carried. It named the board consequence this one only implies: CLOUD-312's end-state predicate 2 (siblings == 0 and merged == 0, or every remainder is a DECLARED row naming a still-open key) could not be satisfied by either disjunct. merged == 2, and a DECLARED row naming CLOUD-605 would name a closed key, which wiring-declaration-closed-owner refuses by design. So predicate 2 was unreachable, and this row is what makes it reachable — on its first disjunct, with no declaration added.

CLOUD-1086 also recorded why nothing noticed: wiring-declaration-closed-owner decides from get_issue payloads piped in on stdin, and the ordinary mise run path pipes none — could-not-look reads as a pass. Both rows sat naming a closed key from 2026-08-23 until #714 emptied the table, silently, on every run. That is CLOUD-1326's class reached by a second route, and it is why the §2 predicate here demands merged_surfaces_read non-zero alongside the zero.

CLOUD-1086's own §1 named "the environment configuration that generates ~/.claude/launcher-settings.json — outside this repository" as its source of truth. That is the retracted sentence (CLOUD-1356) inherited a third time, and it is superseded by this row's corrected §1 rather than carried over.

What is deliberately not proposed — REWRITTEN 2026-09-02

Half of the original refusal survives and half was the punt.

Still refused, and now for the right reason: deleting either FILE. session-start-git-identity.sh sets core.hooksPath for the whole container and that line may be load-bearing. batten wiring reclaim removes registrations, never files — which is exactly why it is the correct instrument and a blind rm is not.

No longer refused: running the repair from this repository. That was the punt, and the core.hooksPath caution is what it hid behind — a real precision requirement on the repair, dressed up as a reason to have no repair.

Honest residue, recorded rather than handed to an owner: a session-start handler closes the session-start window, and it was recorded here that it "does not hold against the 16:59 mid-session rewrite". What would close that is the provisioning seam mise.toml's deps-install comment already describes — the container's setup script runs mise run deps-install before any session begins, upstream of both the rewrite and the harness snapshot.

AND THAT RESIDUE IS NARROWER THAN I WROTE IT — measured 2026-09-02, hours after writing it, on this container. The handler landed and session:wiring ran at ~19:5x, taking merged_siblings 2 → 0. The launcher then rewrote both scripts at 21:25 and launcher-settings.json itself at 21:29, well after the repair. doctor hooks -J still answers merged_siblings: 0, merged_surfaces_read: 1, ok: true, and the rewritten settings file reads:

{ "$schema": "", "hooks": {}, "permissions": { "allow": ["Skill"] } }

"hooks": {} — the mid-session rewrite preserved the reclaimed state rather than restoring the two registrations. So the rewrite this row twice cited as the thing no in-session repair can outlast did not, on this occasion, undo the repair.

Stated as one observation, not a new law, because the symmetric overclaim is what this row keeps having to retract. What is established: a launcher rewrite of launcher-settings.json occurred after a reclaim and did not re-add the hooks. What is NOT established: that it never does — the rewrite may re-add on a cold provision, on a different container class, or when the file is absent rather than present. The handler is what makes that question cheap either way: it runs every session, so a restoration is undone at the next session start whatever the launcher does.

The deps-install seam therefore remains the right place for the cold window (before any session, before any harness snapshot) and is no longer needed for the mid-session one on this evidence. It stays open on this row rather than being closed on a single reading.

Superseded original, kept so the reasoning is auditable:

Deleting the files from $HOME at session start, from this repository or from batten wiring reclaim. That is the race CLOUD-605 measured and it loses: the launcher rewrites them at the next session, and one of the two also sets core.hooksPath for the whole container. A repository fighting its own harness's provisioning is a worse failure than the registrations it is trying to remove.


Why this row went backwards, traced 2026-09-02

It was In Progress 06:18–15:33 today and moved back to Backlog. The loop, and no step needs anyone outside this repository:

  1. Nothing keeps the installed batten current — measured 0.0.121, built Aug 28, against a source tree at 0.0.137.
  2. So batten wiring reclaim — the repair — was absent from the installed CLI (error: unrecognized subcommand 'wiring'), and an agent checking reachability concluded it did not exist.
  3. And CLOUD-1314's test_the_retired_launcher_hooks_are_refused_if_they_return (policy/harness-wiring.rego:576) was not in that binary either, so batten doctor hooks -J answered siblings: 0, ok: true while merged: 2 showed it looking straight at both registrations.
  4. A green census makes this row read as satisfied or unactionable → back to Backlog.

On 0.0.137 the same host answers merged_siblings: 2, ok: false, with a finding per event. policy/harness-declared.json is {}there is no exemption; the rule that refuses these simply was not running.

CLOUD-1326 names this class. The engine's own contract-drift advisory fired during this session and said it plainly: "this session's SessionStart registration did not run … every mediated call until it appeared failed open and said nothing. This is a provisioning failure rather than a policy one."

Review in Linear

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 15 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 51439294-c30c-40ec-b573-fd31209e51c1

📥 Commits

Reviewing files that changed from the base of the PR and between 0c59527 and 498642c.

📒 Files selected for processing (6)
  • AGENTS.md
  • batten.toml
  • crates/batten/src/lib.rs
  • crates/batten/src/transcript.rs
  • crates/batten/tests/it/main.rs
  • schema/batten.schema.json
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 593531a2-cc39-4a43-99da-1b5841a99dd8

📥 Commits

Reviewing files that changed from the base of the PR and between 32eaca9 and 0c59527.

⛔ Files ignored due to path filters (1)
  • crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap is excluded by !**/*.snap
📒 Files selected for processing (12)
  • batten.toml
  • completions/batten.bash
  • completions/batten.fish
  • completions/batten.zsh
  • crates/batten/src/cli.rs
  • crates/batten/src/lib.rs
  • crates/batten/src/spec.rs
  • crates/batten/src/surface.rs
  • crates/batten/src/transcript.rs
  • crates/batten/tests/it/main.rs
  • crates/batten/tests/it/pointer_only.rs
  • schema/batten.schema.json

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds per-session task-store configuration and path resolution. The stop-hook refreshes a repository-local task-store link. A new batten doctor session command reports open task identifiers, supports JSON output, and returns distinct statuses for clean, unfinished, and unreadable stores. It also adds land, perf compare, and perf gate workflows, shell completions, manual pages, schema declarations, operational guidance, policy updates, ignore rules, and integration tests.


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@wenzowski
wenzowski force-pushed the claude/cloud-1376-session-drain branch 3 times, most recently from 1642765 to 4763b27 Compare September 3, 2026 01:05

Copy link
Copy Markdown
Contributor Author

hooks-wiring-check is red on main, not on this PR

verify refused this branch on harness-wiring2 findings. It is not this PR's: nothing here touches wiring, and the same binary reproduces it on a clean worktree of origin/main.

tree batten check --rule harness-wiring
this branch (4763b27a) 2 findings
origin/main (dda531f7) 2 findings
dda531f7^ clean

So dda531f7"drop the two launcher exemptions the repair made stale" — is the breaking commit.

Why it broke, measured rather than argued

That commit set policy/harness-declared.json back to {} on the reading that session:wiring had removed both launcher registrations from the merged surface (2 -> 0), so the exemptions "outlived what they excused".

The reading was true when it was taken and is not a steady state. Measured in this container just now, after dda531f7 landed and after the repair ran:

/root/.claude/stop-hook-git-check.sh          mtime Sep 3 00:55
/root/.claude/session-start-git-identity.sh   mtime Sep 3 00:55

doctor hooks -J → claude-code: siblings 0, merged_siblings 2

The launcher re-provisions both at session start. session:wiring reclaims them; the platform writes them back; the next gate run sees two undeclared merged siblings and refuses.

This is the same defect three times, and that is the finding worth keeping

  1. CLOUD-1314's original deletion — premise "both registrations are gone and both programs are deleted"; measured present with mtime three hours after the deleting commit.
  2. [hook] exclusive = true — declared on a merged_siblings: 0 reading taken minutes after the two files were deleted by hand. Reverted in feat(facts): a vendored prompt's dispatch becomes a fact the tree can read #826: "a count that is zero because you just removed its members is not the count."
  3. dda531f7 — a 2 -> 0 reading taken immediately after the repair ran, one level out: a count that is zero because the repair just fired is not the count either, because the platform re-provisions on the next session start.

Each measured an instant and recorded it as a steady state. The repair is real and works; what it cannot do is outlive the launcher that runs after it.

What I am doing

Porting the minimal restore into this PR so it can reach green, per the drive-to-green rule that a fix which exists is ported rather than waited on — it no-ops the moment main carries its own. It is one JSON file and touches nothing this PR is about.

CLOUD-1079 owns the real resolution, and this comment is not it. The open question that measurement settles is whether the declared rows describe the honest steady state (the hooks keep coming back, so declare them) or whether the repair should be made to win the race with the launcher. That is a decision for the row, not for a green button on this branch.


Generated by Claude Code

@wenzowski
wenzowski force-pushed the claude/cloud-1376-session-drain branch from a6b252a to 404c230 Compare September 3, 2026 03:17

Copy link
Copy Markdown
Contributor Author

Handoff — everything on this branch is pushed; two receipts are NOT and cannot be

All commits are pushed (404c230f). What follows is the state that lives only in this container's .git/batten-receipts/, dies with it, and will make verify refuse for whoever picks this up. Neither refusal explains how it was satisfied, which is why this comment exists.

1. The claim receipt — and it needs --takeover, not a plain claim

.git/batten-receipts/claim.claude-cloud-1376-session-drain

verify refuses without it: "this branch carries no VALID claim receipt".

A plain mise run claim-check will NOT mint it. CLOUD-1376 is already In Progress (the PR-attachment automation moved it), so claim check answers not-todo (in In Progress) and refuses. The honest route is --takeover, which mints the receipt *and records not-todo as the refusal it overrode.

Do not move the row back to Todo to make the gate pass. I started to do exactly that and stopped: editing the state a gate reads, in order to change what it reads, is the same move as [hook] exclusive = true being declared on a merged_siblings: 0 taken right after deleting the files — "a count that is zero because you just removed its members is not the count". The row's true state is In Progress; someone is on it.

The payload must be the tracker's own bytes, since the gate compares a digest:

batten mcp call Linear get_issue '{"id":"CLOUD-1376"}'
id=$(batten capture find --tool get_issue CLOUD-1376 | awk '{print $1}')
batten capture show --raw "$id" | batten claim check --takeover

(13,398 bytes stored, 642 emitted — the CLOUD-1260 route, so the 20KB body never passes through context.)

2. The plan record

.git/batten-receipts/plan.claude-cloud-1376-session-drain.CLOUD-1376

plan-unrecorded refuses a claimed branch that declared no plan — so it only arms after step 1. Re-record:

printf '%s\n' \
  "doctor-session-verb completed" \
  "could-not-look-arms completed" \
  "compiled-binary-tier completed" \
  "agents-prose completed" \
  "config-tasks-template completed" \
  "stop-nudge-rule-six deleted" \
  | batten record plan

stop-nudge-rule-six is deleted deliberately and must stay that way. CLOUD-1376's "what to build" names a sixth stop_nudges rule; it is scoped out because a nudge fires after the turn in which "safe to archive?" is asked, so it cannot prevent the claim — the verb is the half that can. deleted is the store's terminal status for a withdrawal the author is entitled to make, and recording it is what keeps the dropped half visible instead of quietly missing.

3. Regenerable, listed so nobody hunts for them

.claude/.tasks (the engine writes it at the Stop seam — and it is .gitignored on purpose: committing it gives every other checkout a dangling pointer, which doctor session reads as could-not-look forever). The capture store, verify receipts, prune laps: all rebuild themselves.

What is already durable

Nothing else is at risk. The RCA is CLOUD-1376; the dda531f7 base-breakage measurement is in this PR's earlier comment and in 44dba857's message; the heredoc measurement is on CLOUD-1304; the DO-NOT-CLOSE recurrence is on CLOUD-1127; CLOUD-135 is restored to Done with its tree measurement. No finding on this branch lives only in a chat log.

State at handoff

Nine commits. harness-wiring's base breakage is ported here and no-ops once main carries its own fix. Last known refusal was plan-unrecorded, now satisfied locally; land had not completed a lap when the session ended.


Generated by Claude Code

@wenzowski
wenzowski marked this pull request as ready for review September 3, 2026 03:24
@wenzowski
wenzowski marked this pull request as draft September 3, 2026 03:28
@wenzowski
wenzowski force-pushed the claude/cloud-1376-session-drain branch from 5260ad7 to 64d4fbc Compare September 3, 2026 03:57
@wenzowski
wenzowski marked this pull request as ready for review September 3, 2026 04:22
@wenzowski
wenzowski force-pushed the claude/cloud-1376-session-drain branch from 64d4fbc to 0c59527 Compare September 3, 2026 04:44
@wenzowski
wenzowski marked this pull request as draft September 3, 2026 04:44
… being estimated

Measured 2026-09-02: asked "Done? Safe to archive?", the agent enumerated the
working tree, the stash, local branches and running processes, found all four
clean, and answered "yes — safe". At that moment the session's own task store held
`{"id": "21", "status": "pending"}` on disk. The claim was false when it was made,
and a file could have decided it.

WHY THERE WAS NOTHING TO RUN. Every other completion question resolves to a
command — `verify` decides the tree, `land` the PR, `done-check` the release,
`claim-check` the pull. Nothing decided the SESSION. Non-negotiable rule 3 says
gates decide and never estimate, but a rule needs an instance to bind to, and the
one completion claim with no command behind it is the one that was wrong.

COULD-NOT-LOOK IS `3` AND NEVER `0`, which is the whole deliverable rather than a
detail. The defect being fixed is an absent reading reported as a clean one, so
the arm with nothing to read must not share an exit code with the arm that read
and found nothing open. An undeclared template, an unreadable store and one
malformed member all take that arm; a partial count is a number that looks
measured and is not.

`ExitCode::Violation` stays unreachable, for `WiringReport::code`'s reason: a
sub-verb of `doctor` is a diagnosis, a mediating harness reads `2` as a deny, and
"you have unfinished work" is not "policy says no". So `0` clean, `1` open, `3`
could-not-look.

THE STORE'S LOCATION IS THE CONSUMER'S AND THE SUBSTITUTION IS THE ENGINE'S. The
task store lives outside the repository root and its layout is the host's, so
deriving it here would put a directory layout in `crates/batten` — rule 1. The
consumer declares a template carrying `{session}`; the engine substitutes the one
field the envelope already normalises across hosts and opens what that names.

IT JOINS `[transcript]` RATHER THAN OPENING A `[session]` TABLE, on that table's
own stated reasoning: the transcript's format and the host's memory layout are
already "two facts about one host", and splitting them across tables "would be the
widening rule 6 forbids". A third fact about the same host joins them.

The link rides the transcript seam for the same reason it exists: both are
per-session paths outside the root that a committed key must name forever. A
session before its first `Stop` has no link and answers could-not-look.

BREAKING CHANGE: `TranscriptConfig` gains a `tasks` field, so a downstream
struct literal that names every field no longer compiles. `semver` caught it as
`constructible_struct_adds_field`, and the break is declared rather than dodged:
the alternatives were `#[non_exhaustive]`, which is a LARGER break on the same
struct and forecloses construction forever, or a second config table, which the
`[transcript]` doc already rules out as the widening rule 6 forbids. Consumers
deserialize this type from `batten.toml`; the ones who construct it add
`..Default::default()`.

Refs: CLOUD-1376, CLOUD-990, CLOUD-66
…can fail

Seven cases over the compiled binary. Three of them assert exit `3`, and that is
the row rather than defensive padding: the defect being fixed is an ABSENT READING
REPORTED AS A CLEAN ONE, so a verb answering `0` for an unreadable store would
reproduce it exactly, with a command in front of it lending it authority.

TWO ROUTES TO COULD-NOT-LOOK, KEPT APART because the remedies differ — an absent
store means mount it, an undeclared template means declare it. A third, a
malformed member, poisons the whole reading rather than being skipped: a partial
count would report "1 of 1 open" over a store holding two, and under-reporting is
this verb's only failure mode that matters.

SHOWN ABLE TO FAIL, by hand, twice, because a suite of absence assertions is
exactly where a green means nothing (CLOUD-418):

  None => ExitCode::Success        (could-not-look reported as clean — the
                                    ORIGINAL defect, restated as code)
    → reddens an_absent_store_is_could_not_look_and_never_clean,
      an_undeclared_template_is_could_not_look_too and
      one_malformed_member_poisons_the_whole_reading; the other four stay green,
      so the arms discriminate instead of firing together.

  Some(0) => ExitCode::Usage       (refuse unconditionally)
    → reddens a_store_whose_tasks_are_all_completed_is_clean and NOTHING else,
      which is the anti-vacuity mirror doing the one job it exists for. Without
      it every other assertion here is satisfied by a verb that decides nothing.

Each case was run BY ITS OWN NAME. A filter matching fewer tests than intended
over a suite like this is a green that carries no information, measured earlier in
this session at 6/6 passing with the one positive case never running.

The pointer arm is asserted in both directions: the open id reaches the channel
and the task's subject line does not (rule 4). `in_progress` counts as open
because the predicate names `completed` as the one finished state rather than
enumerating the unfinished ones — a status the harness adds later must count as
open, not slip through a list nobody updated.

Refs: CLOUD-1376, CLOUD-418
…mes the other

"committed-and-pushed is the only state that survives a VM reclaim" is true of the
TREE and was read as the whole question. It is the sentence that scoped a
completion claim to git: asked "safe to archive?", a session enumerated the working
tree, the stash, local branches and running processes, found all four clean, and
answered "yes" while its own task store held a `pending` entry on disk.

The environment taught the same shape from the other side. The only end-of-session
signal that actually speaks in this container is the launcher's
`stop-hook-git-check.sh`, which nags about unpushed commits and nothing else — so
the recurring teacher of "what does end-of-session mean" asks a git question, and a
git answer feels complete.

THIS SHIPS WITH ITS MECHANISM, which is non-negotiable rule 2 and is also the
reason the earlier commits come first: prose here would be half a change, and half
a change is exactly what failed. `batten doctor session` is the other half.

PAID FOR IN WORDS RATHER THAN APPENDED. `policy-budget` refused three drafts —
3566 tokens of 3500 and 204 lines of 199, then 201, then 200 — because this file
was already sitting exactly on its line cap. That is the budget working: a rule
that cannot earn its lines against the rules already here does not belong in the
file that binds every turn. What survived is the load-bearing clause; the
measurement, the exit table and the reasoning live in `doctor.rs`, in
`session_drain.rs` and on the row, where a reader who needs them will be.

Refs: CLOUD-1376
The consumer half of CLOUD-1376. `doctor session` reads the declared template to
decide whether it may look at all, so until this lands the verb answers
could-not-look on every invocation here — honest, and useless.

MEASURED ON THIS TREE, which is the acceptance clause rather than a demonstration.
Pointing the link at a copy of this session's own store with `21.json` restored to
the state it held at 22:35:

  doctor session: 1 of 21 declared task(s) open — 21          exit 1

and against the live store, where that task is now finished:

  doctor session: 0 of 21 declared task(s) open               exit 0

The first line is the answer the session gave as "yes — safe".

`~/` EXPANDS, and that is correctness rather than convenience. These stores live
under the launcher's home, no committed value may name a container's absolute home
path, and an unexpanded `~` reaches no directory — which reads exactly like a
consumer with no work. The expansion takes the home as a PARAMETER rather than
reading `HOME` inside, so the function stays pure and its unit cases do not mutate
process-wide state other tests race with. An absent or empty home leaves the
template alone, so the caller's `is_dir` takes the could-not-look arm.

Refs: CLOUD-1376
Admits: 92bbe52e93f6fd8f4377741662c89403ceec378fb60d7fb37ee20c3bc9229600
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 6c42d09
Admits-epoch: 325d7b80d70f362133ec009d2edb069dc0d888657df819a86da5e2cf043e0e3b
Admits-author: alec@wenzowski.com
Admits-prev: 1e307c3d9dcc2ea28e3a6a813a06f1b1c6eef4aa8e3cfb324441c7a9d14686fc
Admits-answer-lost: The verb this branch just built answers could-not-look forever here. `doctor session` reads the declared template to decide whether it may look at all, so undeclared it returns 3 on every invocation — honest and useless — and batten stops being able to answer the question about ITSELF that CLOUD-1376 exists to make answerable. Measured on this tree: with the key declared and the store linked, the verb returns 1 and names task 21 for the 22:35 state and 0 for the 22:40:51 state; undeclared, both are 3.
Admits-answer-precondition: No surface sets this value. `[transcript] tasks` is a NEW config key landing in the same branch that introduced it, and there is no verb that writes a config key — `config lint` reads, `config show` renders, and neither declares. The change is one line of TOML plus its header comment, and it lands in the diff a reviewer reads as exactly that: the consumer naming where this host keeps its session task store.
Admits-answer-rejected-route: `config read first` is what produced this change rather than an alternative to it: reading `[transcript]`'s own header is how the key found its home there instead of in a new `[session]` table, because that header already states why the transcript format and the memory root share a table and why a second authority over one host would be the widening rule 6 forbids. `patch run first` has no patch to run — `mise run fmt` normalises TOML it is given and writes no key, and nothing else in the task list declares config.
`git add -A` swept up `.claude/.tasks` — the runtime symlink created while proving
the verb by hand — and committed it pointing at
`/root/.claude/tasks/<this container's session id>`.

WHY THAT IS A DEFECT AND NOT UNTIDINESS. A committed link names ONE container's
session, so every other checkout inherits a dangling pointer, and `doctor session`
reads a dangling link as could-not-look on every invocation. The verb that exists
to stop an absent reading passing as a clean one would itself have shipped unable
to look, everywhere but here.

The precedent is two lines above it in this file: `[transcript]`'s link carries the
same rule for a different reason — committing that one leaks a session's prose,
committing this one leaks nothing and breaks everyone. Both are written by the
engine at the Stop seam and neither belongs in the tree.

FOUND BY A REVIEW BOT'S FILE LIST rather than by me or by a gate. CodeRabbit
enumerated the eleven files in the diff and `.claude/.tasks` was one of them; no
check refuses a tracked symlink into an absolute path, and `.gitignore` is the
mechanism that stops the next one.

Refs: CLOUD-1376, CLOUD-97
…clared again

PORTED, NOT THIS BRANCH'S. `harness-wiring` refuses `origin/main` itself — same
binary, clean worktree, 2 findings on `dda531f7` and none on `dda531f7^`. This
branch touches no wiring. It is carried here because a fix that exists is ported
rather than waited on, and it no-ops the moment `main` carries its own.

WHAT dda531f MEASURED, AND WHY IT DOES NOT HOLD. It set the table to `{}` because
`session:wiring` had just reclaimed both launcher registrations from the merged
surface, 2 -> 0, so the exemptions "outlived what they excused". True when taken.
Measured in this container after that commit landed and after the repair ran:
both programs present with mtime 00:55, and `doctor hooks -J` reporting
`siblings: 0, merged_siblings: 2`. The launcher re-provisions at session start,
so the repair wins the moment it runs and loses by the next gate.

THE SAME DEFECT, THREE TIMES, AND THE THIRD IS THE SUBTLEST. CLOUD-1314 deleted
the rows on "both registrations are gone"; measured present three hours later.
`[hook] exclusive = true` was declared on a `merged_siblings: 0` taken minutes
after deleting the files by hand, and its revert records the rule: a count that is
zero because you just removed its members is not the count. This is that rule one
level out — a count that is zero because the REPAIR just fired is not the count
either, because the thing being repaired is rewritten by something the repair does
not control.

WHAT THIS COMMIT DOES NOT DECIDE. Whether declaring the rows is the right
steady-state answer, or whether the repair should be made to win the race, is
CLOUD-1079's. The rows name it, `spent` and `stale` still watch them, and they
leave when the launcher stops writing them. This restores a green base; it does
not settle the design.

Refs: CLOUD-1079, CLOUD-1314
`spec::tests::the_emitted_surface_is_exactly_the_committed_row_set` refused the
tree: the emitted surface carried `doctor session` and the committed row set did
not. The surface is spec-as-data (house-style §11), so adding a `CommandDecl` is
half the change — the completions, the man pages and the golden schema snapshot
are derived from it and are committed, which is what makes that assertion able to
fail at all.

Regenerated with `mise run fix` rather than edited by hand, for the reason the
schema regeneration earlier on this branch had: a hand-written derived artifact
agrees with the generator only until the next reader, and the gate compares
against the generator.

`man/batten-doctor-session.1` is new; `batten-doctor.1` gains the sub-verb; the
three shell completions gain the token. The one SOURCE change is clippy's, and it
is behaviour-identical: a nested `if` collapsed into the `&& let` chain this file
already uses elsewhere.

Refs: CLOUD-1376
`allowlist_is_exactly_the_read_commands` and
`the_emitted_surface_is_exactly_the_committed_row_set` both refused the tree, and
neither is a list `mise run fix` can regenerate — that is the design rather than a
gap. The second says so in its own comment: a verb added, renamed or re-parented
"fails here and has to be STATED, which is the prompt to reconcile §2 in the same
change". A generated list would agree with the generator by construction and
assert nothing about what a human decided.

THE READ-ONLY ENTRY IS THE ONE THAT NEEDED AN ARGUMENT, since house-style §5's
allowlist is the safety-critical half. `doctor session` is `read` structurally,
not by promise: it opens the session's task store through a link the engine
parked, counts the members whose `status` is not `completed`, and returns. No
spawn, no write, no network — and `the_process_spawning_verb_is_never_read_only`
and `the_mediation_entrypoint_is_never_read_only` are the two assertions that stop
that claim being made carelessly. Both green.

Refs: CLOUD-1376, CLOUD-244, CLOUD-777
…to print it

`every_leaf_verb_is_classified` refused the new sub-verb, and the census then
refused the easy way out — which is the better half of this commit.

WHAT IT CAUGHT. Adding the row was not enough: with no task store in the corpus,
`doctor session` answers could-not-look, and the sweep asserts `code != Some(3)`
before it will read the output at all — "failed internally, so what it did not
emit proves nothing". A verb that never reached its reporting path cannot
demonstrate that the path is clean. `MAY_ANSWER_COULD_NOT_LOOK` would have
silenced that, and it would have bought a row in a census that never exercised
the verb: the vacuous pass this file exists to refuse, entered through its own
exemption list.

SO THE CORPUS SEEDS THE STORE, and the seed is a CONTENT canary because that is
what a task subject is — free text an agent wrote, the same class as a matched
line or a transcript's prose. The verb now reads it on every sweep run and must
emit the id and the counts without it. `the_corpus_is_live_subject_matter` is
what holds the seed reachable, so this cannot decay into a canary nothing reads.

A real directory rather than a symlink: `read_dir` follows either, so the reading
under test is identical, and the fixture does not depend on how a platform spells
a link.

ONE DEFECT FOUND IN WRITING IT, and it is this branch's recurring shape a third
time: `tasks = "/nonexistent/{session}"` sits inside a `format!`, which consumed
`{session}` as a named argument. The compiler caught it here. The same template
written where no compiler looks is a placeholder that silently resolves to
nothing — exactly the unexpanded `~` earlier on this branch, and exactly what
could-not-look-versus-clean exists to keep visible.

Refs: CLOUD-1376, CLOUD-92, CLOUD-418
… an absent home

`clippy::unnecessary_wraps` refused a helper I wrote: `home()` returned
`Option<&OsStr>` and every arm of it was `Some`, so the wrapper carried no
information.

THE FIX IS THE BETTER SHAPE RATHER THAN AN `#[allow]`. `tasks_dir` takes an
`Option` because an absent HOME is a real reading — an unexpanded `~` names no
directory and the caller's `is_dir` check then takes the honest arm instead of
this function inventing a home. Wrapping inside the helper hid exactly that
distinction at the call sites: a reader could not tell which cases were ABOUT
absence and which merely supplied a home. `Some(home(...))` at each site puts it
back where it can be read, and the two absence cases now stand out by being the
ones that do not say `Some(home(...))`.

`cargo clippy --all-targets`: 0 findings. `transcript::tests`: 19 passed.

Refs: CLOUD-1376
… path join

Windows CI refused the tree, and the failure is mine:

    left:  "/home/agent\.claude/tasks/s-1"
    right: "/home/agent/.claude/tasks/s-1"

`tasks_dir` expanded `~/` through `Path::join`, which inserts the PLATFORM's
separator. So the engine rewrote a separator the CONSUMER chose — on a template
that is the consumer's string, in a function whose whole contract is that it
knows one placeholder and one prefix and returns everything else verbatim. That
is rule 1's boundary in miniature: the layout is declared rather than derived,
and a join derives.

WHY LOCAL VERIFY COULD NOT SEE IT. The assertion was correct and passed here; the
two platforms disagreed rather than the logic being wrong, so no amount of running
it on Linux would have shown anything. That is the class CI exists for, and it is
distinct from a failure verify skipped.

THE NEW ARM MAKES IT VISIBLE EVERYWHERE. A Windows-shaped home —
`D:\Users\agent` — asserts the home comes back verbatim and the template's own
`/` survives, so the property is now checked on every platform rather than only
on the one that disagreed. Re-introducing the join reddens it here.

`transcript::tests`: 19 passed. `clippy --all-targets`: 0 findings.

Refs: CLOUD-1376
@wenzowski
wenzowski marked this pull request as ready for review September 3, 2026 05:28
@wenzowski
wenzowski force-pushed the claude/cloud-1376-session-drain branch from 0c59527 to 498642c Compare September 3, 2026 05:28
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 498642c into main Sep 3, 2026
10 of 11 checks passed
@wenzowski
wenzowski deleted the claude/cloud-1376-session-drain branch September 3, 2026 05:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant