From fa3f22a7d560b57b11d668afe867c886105e1967 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 00:27:31 +0000 Subject: [PATCH 01/11] feat(doctor)!: give "is this session safe to end" a verb, so it stops being estimated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/batten/src/cli.rs | 8 +++ crates/batten/src/doctor.rs | 105 ++++++++++++++++++++++++++++++++ crates/batten/src/lib.rs | 105 ++++++++++++++++++++++++++++++++ crates/batten/src/surface.rs | 27 ++++++++ crates/batten/src/transcript.rs | 42 +++++++++++++ schema/batten.schema.json | 7 +++ 6 files changed, 294 insertions(+) diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index b09975b24..165fb9459 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -1133,6 +1133,11 @@ pub enum DoctorCommand { /// Emit the per-harness diagnosis as byte-stable JSON. json: bool, }, + /// Whether this session has declared work it has not finished. + Session { + /// Emit the count and the open ids as byte-stable JSON. + json: bool, + }, } /// Subcommands of `generate`. @@ -1457,6 +1462,9 @@ fn doctor_of(matches: &ArgMatches) -> DoctorCommand { Some(("hooks", matches)) => DoctorCommand::Hooks { json: flag(matches, "json"), }, + Some(("session", matches)) => DoctorCommand::Session { + json: flag(matches, "json"), + }, // The bare verb reads `-J` from its OWN matches, which is where clap put // it when no subcommand was given. _ => DoctorCommand::Diagnose { diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index 4b73ffc09..326ef1322 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -1164,6 +1164,111 @@ pub fn diagnose_hooks(dir: &Path) -> WiringReport { } } +/// One session's own declared-open work, as `doctor session` renders it. +/// +/// `#[non_exhaustive]` for [`WiringReport`]'s reason. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[non_exhaustive] +pub struct SessionReport { + /// The running binary's version. + pub version: &'static str, + /// How many declared tasks are not `completed`, or `None` for + /// could-not-look. + /// + /// **THREE-VALUED, AND THAT IS THE WHOLE ROW** (CLOUD-1376). An unreadable + /// store, an undeclared template and a store with nothing open are three + /// different answers, and collapsing the first into the third is exactly the + /// false clean this verb exists to refuse: "no store" must never read as + /// "nothing left to do". + pub open: Option, + /// How many tasks the store holds at all, or `None` for could-not-look. + pub total: Option, + /// The ids of the open tasks, sorted — a POINTER set, never a subject line. + /// + /// Non-negotiable rule 4: an id sends a reader to the task; a subject would + /// return the session's own prose to it, which is the mirror a restatement + /// can clear. + pub ids: Vec, + /// Whether the session has nothing open. False for could-not-look. + pub ok: bool, +} + +/// Read the live session's task store and count what is not finished. +/// +/// # Why this is a verb and not only a nudge +/// +/// The question *is this session safe to end* arrives INSIDE a turn, and a +/// `Stop` rule answers after one. A verb is what lets the question be answered +/// by an exit code rather than by an opinion — which is the whole defect +/// CLOUD-1376 records, where the store held `pending` and the answer given was +/// "safe". +#[must_use] +pub fn diagnose_session(dir: &Path) -> SessionReport { + let unreadable = SessionReport { + version: config::VERSION, + open: None, + total: None, + ids: Vec::new(), + ok: false, + }; + let Some(link) = resolve::resolve(dir, &crate::Overrides::default()) + .ok() + .and_then(|resolved| { + let transcript = resolved.transcript.as_ref()?; + // The template's ABSENCE is could-not-look rather than clean: a + // consumer that never declared a store has not told us it has no + // work. + transcript.tasks.as_ref()?; + crate::transcript::tasks_link(dir, transcript.path.as_deref()?) + }) + else { + return unreadable; + }; + let Ok(entries) = std::fs::read_dir(&link) else { + return unreadable; + }; + let mut total = 0usize; + let mut ids = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "json") { + continue; + } + let Ok(bytes) = std::fs::read_to_string(&path) else { + // ONE unreadable member poisons the whole reading. A partial count + // is a number that looks measured and is not, and this verb's only + // failure mode that matters is under-reporting. + return unreadable; + }; + let Ok(task) = serde_json::from_str::(&bytes) else { + return unreadable; + }; + total += 1; + if task.get("status").and_then(serde_json::Value::as_str) != Some("completed") { + if let Some(id) = task.get("id").and_then(serde_json::Value::as_str) { + ids.push(id.to_owned()); + } + } + } + // Byte-stable output (§6): directory order is the filesystem's, and a report + // whose id list reorders between runs is not byte-stable. + ids.sort_by(|left, right| { + let numeric = left + .parse::() + .ok() + .zip(right.parse::().ok()) + .map(|(left, right)| left.cmp(&right)); + numeric.unwrap_or_else(|| left.cmp(right)) + }); + SessionReport { + version: config::VERSION, + open: Some(ids.len()), + total: Some(total), + ok: ids.is_empty(), + ids, + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 2d49629b9..a5fa21096 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -8886,6 +8886,18 @@ fn stop_nudges(overrides: &Overrides, envelope: &hook::Envelope) -> Option Result run_diagnose(json, out), cli::DoctorCommand::Hooks { json } => run_doctor_hooks(json, out), + cli::DoctorCommand::Session { json } => run_doctor_session(json, out), } } @@ -12610,6 +12664,57 @@ fn run_doctor_hooks(json: bool, out: &mut dyn Write) -> Result { Ok(report.code()) } +/// `doctor session` — the exit code that answers "is this session safe to end". +/// +/// Three codes for three answers, which is the whole point of the verb +/// (CLOUD-1376): `0` nothing open, `1` work declared and unfinished, `3` +/// could-not-look. A caller may quote the code; it cannot quote an opinion. +/// +/// [`ExitCode::Violation`] is unreachable, for [`doctor::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". +/// +/// **Could-not-look is `3` and never `0`.** That single mapping is the row's +/// deliverable: the defect being fixed 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. +fn run_doctor_session(json: bool, out: &mut dyn Write) -> Result { + let report = doctor::diagnose_session(Path::new(".")); + if json { + writeln!(out, "{}", serde_json::to_string_pretty(&report)?)?; + return Ok(session_code(&report)); + } + match (report.open, report.total) { + (Some(open), Some(total)) if open > 0 => { + writeln!( + out, + "doctor session: {open} of {total} declared task(s) open — {}", + report.ids.join(" ") + )?; + } + (Some(_), Some(total)) => { + writeln!(out, "doctor session: 0 of {total} declared task(s) open")?; + } + // Silent on stdout for the unreadable arm: §6 keeps a could-not-look off + // the data channel, and the exit code carries it. + _ => { + writeln!( + out, + "doctor session: no readable task store — this is could-not-look, never a clean" + )?; + } + } + Ok(session_code(&report)) +} + +fn session_code(report: &doctor::SessionReport) -> ExitCode { + match report.open { + None => ExitCode::Internal, + Some(0) => ExitCode::Success, + Some(_) => ExitCode::Usage, + } +} + fn run_spec(format: SpecFormat, out: &mut dyn Write) -> Result { let described = spec::document(&surface::command()); match format { diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 88e9d2d9f..22b0f9111 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -2357,6 +2357,33 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Read, flags: &[JSON], }, + // THE VERB "IS THIS SESSION SAFE TO END" DID NOT HAVE (CLOUD-1376). + // + // 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, so the one completion claim with no command + // behind it was answered by estimate — measured, with the store on disk + // reading `pending` and the answer given as "safe". Non-negotiable rule 3 + // says gates decide and never estimate; a rule needs an instance to bind to, + // and this is that instance. + // + // A SUB-VERB OF `doctor` rather than a new top-level verb, for the reason + // `doctor hooks` already carries: §2 spells `doctor ` as the shape for a + // focused sub-diagnostic, and *can batten's operator stop here* is a question + // about whether this setup is finished, not a policy verdict over the tree. + // + // `read`, structurally: it opens the session's own task store and counts. It + // spawns nothing and writes nothing. + CommandDecl { + path: "doctor session", + id: "doctor.session", + about: "Diagnose whether this session has declared work it has not finished", + // The open ids are the pointer set a reader acts on, and `-J` is where + // they go — never a task's subject line (rule 4). + data_channel: true, + effect: Effect::Read, + flags: &[JSON], + }, // The scaffolding half of §12's onboarding pair, and the one verb whose // write target is inside the repository. `write` rather than `destructive`: // it creates a file and replaces nothing — an existing config is refused diff --git a/crates/batten/src/transcript.rs b/crates/batten/src/transcript.rs index 5a5207e80..40a1094d2 100644 --- a/crates/batten/src/transcript.rs +++ b/crates/batten/src/transcript.rs @@ -1130,6 +1130,48 @@ pub struct TranscriptConfig { /// inside the repo root. #[serde(default, skip_serializing_if = "Option::is_none")] pub memory_root: Option, + /// Where this host keeps the session's own task store, with `{session}` + /// standing in for the id the envelope carries (CLOUD-1376). + /// + /// ON THIS TABLE RATHER THAN A `[session]` ONE, and the reason is this + /// table's own: 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 rather than opening a second authority over the same subject. + /// + /// A TEMPLATE, because the engine may not derive it. The store lives outside + /// the repository root and its layout is the HOST's — deriving it from the + /// transcript path would put a directory layout in the engine, which rule 1 + /// refuses. The consumer names the shape; the engine substitutes one field it + /// was handed and opens what the substitution names. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tasks: Option, +} + +/// Where the engine parks a pointer at the live session's task store. +/// +/// DERIVED FROM `path` RATHER THAN DECLARED, on the same reasoning that put +/// `tasks` on this table: a second declared key would be a second thing to keep +/// in step for a location nobody chooses independently. The link sits beside the +/// transcript link because it answers about the same session. +/// +/// `None` when no transcript path is declared — there is then nowhere this +/// engine has been told it may write, and inventing one would be the widening +/// house-style §8 refuses. +#[must_use] +pub fn tasks_link(root: &std::path::Path, declared_path: &str) -> Option { + let link = root.join(declared_path); + Some(link.parent()?.join(".tasks")) +} + +/// Substitute the one field the envelope carries into the declared template. +/// +/// The whole of the engine's knowledge about the host's layout: one named +/// placeholder. Everything either side of it is the consumer's string, which is +/// what keeps a directory layout out of `crates/batten` (rule 1). +#[must_use] +pub fn tasks_dir(template: &str, session: &str) -> String { + template.replace("{session}", session) } /// Validate the table at load, the way every other config table is. diff --git a/schema/batten.schema.json b/schema/batten.schema.json index b92ad6ed7..348762123 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -3822,6 +3822,13 @@ "string", "null" ] + }, + "tasks": { + "description": "Where this host keeps the session's own task store, with `{session}`\nstanding in for the id the envelope carries (CLOUD-1376).\n\nON THIS TABLE RATHER THAN A `[session]` ONE, and the reason is this\ntable's own: the transcript's format and the host's memory layout are\nalready \"two facts about one host\", and splitting them across tables\nwould be the widening rule 6 forbids. A third fact about the same host\njoins them rather than opening a second authority over the same subject.\n\nA TEMPLATE, because the engine may not derive it. The store lives outside\nthe repository root and its layout is the HOST's — deriving it from the\ntranscript path would put a directory layout in the engine, which rule 1\nrefuses. The consumer names the shape; the engine substitutes one field it\nwas handed and opens what the substitution names.", + "type": [ + "string", + "null" + ] } }, "additionalProperties": false From 51128d1e68429485b5bd8b59a5957083a8de1c26 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 00:31:09 +0000 Subject: [PATCH 02/11] test(doctor): pin the session verb, and prove the could-not-look arm can fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/session_drain.rs | 203 ++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 crates/batten/tests/it/session_drain.rs diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index ee5bb02f9..393ed8592 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -188,6 +188,7 @@ mod sbom_inventory; mod scanner_taxonomy; mod secrets_kind; mod semver_gate; +mod session_drain; mod session_provisioning; mod shell_retirement; mod shell_write_advisory; diff --git a/crates/batten/tests/it/session_drain.rs b/crates/batten/tests/it/session_drain.rs new file mode 100644 index 000000000..3b985966a --- /dev/null +++ b/crates/batten/tests/it/session_drain.rs @@ -0,0 +1,203 @@ +//! `batten doctor session` over the compiled binary (CLOUD-1376). +//! +//! # The defect this tier exists to keep closed +//! +//! 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". The session's own task store held +//! `{"id": "21", "status": "pending"}` on disk at that moment. Every other +//! completion question resolves to a command — `verify` decides the tree, `land` +//! the PR, `done-check` the release — and nothing decided the SESSION, so the one +//! claim with no command behind it was the one that was wrong. +//! +//! # Why the could-not-look arms are half this file +//! +//! The failure being fixed is **an absent reading reported as a clean one**, so +//! the arms that assert `3` are not defensive extras — they are the row. A +//! version of this verb that answered `0` for an unreadable store would reproduce +//! the original defect exactly, with a command in front of it lending it +//! authority. `an_absent_store_is_could_not_look_and_never_clean` and +//! `an_undeclared_template_is_could_not_look_too` are the two ways to reach that +//! arm, kept apart because they have different remedies: mount the store, or +//! declare the template. +//! +//! # The mirror is what stops the vacuous pass +//! +//! `a_store_whose_tasks_are_all_completed_is_clean` is the anti-vacuity case +//! (CLOUD-418). Without it, every assertion here is satisfied by a verb that +//! refuses unconditionally, which is a gate that decides nothing while reading +//! green. +//! +//! The fixture writes a real directory where the engine parks a symlink. +//! `read_dir` follows a link, so the reading under test is identical, and a real +//! directory keeps the fixture from asserting a property of `symlink` on a +//! platform that spells it differently. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; +use std::process::Output; + +use common::{batten, stdout}; + +/// A repository declaring a transcript path and a task-store template. +/// +/// The template is never resolved by these cases — `doctor session` reads the +/// link the hook parks, and the substitution is exercised where it lives. What +/// the key's PRESENCE decides here is could-not-look versus a real reading, +/// which is the distinction `an_undeclared_template_is_could_not_look_too` pins. +const DECLARED: &str = "version = 1\n\n[transcript]\npath = \".claude/.transcript.jsonl\"\ntasks = \"/nonexistent/{session}\"\n"; + +/// The same repository with the store undeclared. +const UNDECLARED: &str = "version = 1\n\n[transcript]\npath = \".claude/.transcript.jsonl\"\n"; + +fn scratch(name: &str, config: &str) -> PathBuf { + let dir = common::scratch_outside_tree("batten-session-e2e", name); + common::git_in(&dir, &["init", "-q"]); + common::write(&dir, "batten.toml", config); + dir +} + +/// Park one task file in the store the derived link names. +fn task(dir: &Path, id: &str, status: &str) { + common::write( + dir, + &format!(".claude/.tasks/{id}.json"), + &format!( + "{{\n \"id\": \"{id}\",\n \"subject\": \"a declared unit of work\",\n \"status\": \"{status}\"\n}}\n" + ), + ); +} + +fn session(dir: &Path, extra: &[&str]) -> Output { + let mut command = batten(); + command.args(["doctor", "session"]); + command.args(extra); + command + .current_dir(dir) + .env_remove("BATTEN_STRICTNESS") + .env_remove("BATTEN_FAIL_ON_WARNING") + .env_remove("BATTEN_CONFIG_FROM") + .output() + .expect("run batten doctor session") +} + +#[test] +fn a_store_with_an_open_task_is_refused_and_names_its_id() { + let dir = scratch("open-task", DECLARED); + task(&dir, "21", "pending"); + task(&dir, "20", "completed"); + + let output = session(&dir, &[]); + assert_eq!(output.status.code(), Some(1), "got: {}", stdout(&output)); + assert!( + stdout(&output).contains("1 of 2 declared task(s) open"), + "got: {}", + stdout(&output) + ); + // The POINTER, never the payload (rule 4): an id sends a reader to the task, + // and the subject would hand the session its own prose back. + assert!(stdout(&output).contains("21"), "got: {}", stdout(&output)); + assert!( + !stdout(&output).contains("a declared unit of work"), + "the subject line must not reach the channel: {}", + stdout(&output) + ); +} + +#[test] +fn a_store_whose_tasks_are_all_completed_is_clean() { + // THE ANTI-VACUITY MIRROR. Every other case here asserts a refusal, and a + // verb that refused unconditionally would satisfy all of them. + let dir = scratch("all-done", DECLARED); + task(&dir, "1", "completed"); + task(&dir, "2", "completed"); + + let output = session(&dir, &[]); + assert_eq!(output.status.code(), Some(0), "got: {}", stdout(&output)); + assert!( + stdout(&output).contains("0 of 2 declared task(s) open"), + "got: {}", + stdout(&output) + ); +} + +#[test] +fn an_absent_store_is_could_not_look_and_never_clean() { + // The whole row in one case: a store that is not there has told us NOTHING + // about whether work remains, and `0` here would be the original defect with + // a command in front of it. + let dir = scratch("absent-store", DECLARED); + + let output = session(&dir, &[]); + assert_eq!(output.status.code(), Some(3), "got: {}", stdout(&output)); + assert!( + stdout(&output).contains("could-not-look"), + "got: {}", + stdout(&output) + ); +} + +#[test] +fn an_undeclared_template_is_could_not_look_too() { + // A DIFFERENT ROUTE TO THE SAME ARM, and kept separate because the remedies + // differ: this one is "declare the store", the case above is "mount it". A + // consumer that never declared a task store has not told us it has no work. + let dir = scratch("undeclared", UNDECLARED); + task(&dir, "7", "pending"); + + let output = session(&dir, &[]); + assert_eq!(output.status.code(), Some(3), "got: {}", stdout(&output)); +} + +#[test] +fn one_malformed_member_poisons_the_whole_reading() { + // A PARTIAL COUNT IS A NUMBER THAT LOOKS MEASURED AND IS NOT. Skipping the + // unreadable member would report "1 of 1 open" over a store holding two, and + // under-reporting is this verb's only failure mode that matters. + let dir = scratch("malformed", DECLARED); + task(&dir, "1", "pending"); + common::write(&dir, ".claude/.tasks/2.json", "{ this is not json\n"); + + let output = session(&dir, &[]); + assert_eq!(output.status.code(), Some(3), "got: {}", stdout(&output)); +} + +#[test] +fn the_json_channel_carries_the_ids_and_no_subject() { + let dir = scratch("json-shape", DECLARED); + task(&dir, "3", "pending"); + task(&dir, "1", "in_progress"); + + let output = session(&dir, &["-J"]); + assert_eq!(output.status.code(), Some(1), "got: {}", stdout(&output)); + let report: serde_json::Value = serde_json::from_str(&stdout(&output)).expect("parse -J"); + assert_eq!(report["open"], 2); + assert_eq!(report["total"], 2); + assert_eq!(report["ok"], false); + // Sorted NUMERICALLY, not by directory order: §6 wants byte-stable output, + // and the filesystem does not promise an order at all. + assert_eq!(report["ids"][0], "1"); + assert_eq!(report["ids"][1], "3"); + assert!( + !stdout(&output).contains("a declared unit of work"), + "got: {}", + stdout(&output) + ); +} + +#[test] +fn in_progress_counts_as_open() { + // `completed` is the ONLY finished state, and the predicate says so by + // naming it rather than by enumerating the unfinished ones — a status the + // harness adds later must count as open, not slip through a list nobody + // updated. + let dir = scratch("in-progress", DECLARED); + task(&dir, "4", "in_progress"); + + let output = session(&dir, &[]); + assert_eq!(output.status.code(), Some(1), "got: {}", stdout(&output)); +} From 9b1ef758edf519223e5c5a4bd218488442cf3be2 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 00:33:24 +0000 Subject: [PATCH 03/11] docs(agents): the survival sentence says which half it covers, and names the other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e4245cc9..4045afd35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,9 +144,9 @@ a pager (the exit status becomes the pager's) or detaching it with `nohup`/`&` (the wake-up is lost). Redirect to a file; put `run_in_background` on the long command, never on a launcher that returns at once. Gated by `verdict-not-discarded`. **Never** use a foreground `sleep`, spin a foreground busy-poll, or end a turn idle -"to watch" something — background it and act on its exit, and commit first, since -**committed-and-pushed is the only state that survives a VM reclaim**. A bounded -background run means a real exit condition, not a wall-clock cap on the CI poll. +"to watch" something — background it, act on its exit, and commit first, since +**committed-and-pushed is the only state surviving a reclaim, and that is the TREE's +half**: declared work dies too, so **"safe to end?" is `batten doctor session`**. ## Non-negotiable project rules From 6594f4b3a2fa08cbbdee9b010cb750d4091a3d2f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 00:36:59 +0000 Subject: [PATCH 04/11] feat(config): declare where this host keeps the session task store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 6c42d09c08852efae08a58819c53f5fefd286e75 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. --- .claude/.tasks | 1 + batten.toml | 20 ++++++++ crates/batten/src/lib.rs | 3 +- crates/batten/src/transcript.rs | 83 ++++++++++++++++++++++++++++++++- 4 files changed, 104 insertions(+), 3 deletions(-) create mode 120000 .claude/.tasks diff --git a/.claude/.tasks b/.claude/.tasks new file mode 120000 index 000000000..82c43c280 --- /dev/null +++ b/.claude/.tasks @@ -0,0 +1 @@ +/root/.claude/tasks/f1c4264a-538a-5e51-8b73-917e6ed8e791 \ No newline at end of file diff --git a/batten.toml b/batten.toml index 0582d549e..bbbbbf6f2 100644 --- a/batten.toml +++ b/batten.toml @@ -266,8 +266,28 @@ mutation = "change it in a pull request — a registered module is protected bec # observed zero. # # `memory_root` is omitted, so `selfwrite::DEFAULT_MEMORY_ROOT` applies. +# +# `tasks` NAMES THIS HOST'S SESSION TASK STORE (CLOUD-1376), and it is the +# consumer's fact rather than the engine's: the store sits outside the repository +# root and its layout belongs to the launcher, so deriving it in `crates/batten` +# would be a directory layout in the core (rule 1). `{session}` is the ONLY thing +# the engine substitutes, from the id the envelope already normalises across +# hosts, and `batten doctor session` opens what that names. +# +# WHY IT IS ON THIS TABLE. The header above already carries two facts about one +# host — the transcript's format and the memory root — and `transcript.rs` states +# why they share a table rather than splitting: a second table over the same +# subject is the widening rule 6 forbids. The task store is a third fact about +# that same host. +# +# WHAT DECLARING IT BUYS, measured 2026-09-02: asked "safe to archive?", this +# consumer's own agent enumerated the working tree, the stash, local branches and +# running processes, found all four clean, and answered yes — while +# `21.json` in that store read `"status": "pending"`. Undeclared, the verb answers +# could-not-look, which is honest and useless. Declared, it answers `1`. [transcript] path = ".claude/.transcript.jsonl" +tasks = "~/.claude/tasks/{session}" # --------------------------------------------------------------------------- # Pinned tools (CLOUD-90), fetched and cached out of tree. diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index a5fa21096..ea6212de9 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -9048,7 +9048,8 @@ fn refresh_tasks_link(root: &Path, envelope: &hook::Envelope) { else { return; }; - let source = crate::transcript::tasks_dir(template, session); + let source = + crate::transcript::tasks_dir(template, session, std::env::var_os("HOME").as_deref()); if !Path::new(&source).is_dir() { return; } diff --git a/crates/batten/src/transcript.rs b/crates/batten/src/transcript.rs index 40a1094d2..e47f59940 100644 --- a/crates/batten/src/transcript.rs +++ b/crates/batten/src/transcript.rs @@ -1169,9 +1169,33 @@ pub fn tasks_link(root: &std::path::Path, declared_path: &str) -> Option String { - template.replace("{session}", session) +pub fn tasks_dir(template: &str, session: &str, home: Option<&std::ffi::OsStr>) -> String { + let substituted = template.replace("{session}", session); + let Some(rest) = substituted.strip_prefix("~/") else { + return substituted; + }; + match home { + Some(home) if !home.is_empty() => std::path::Path::new(home) + .join(rest) + .to_string_lossy() + .into_owned(), + _ => substituted, + } } /// Validate the table at load, the way every other config table is. @@ -1225,6 +1249,61 @@ mod tests { parse(SAMPLE, "t.jsonl").expect("parses") } + fn home(path: &str) -> Option<&std::ffi::OsStr> { + Some(std::ffi::OsStr::new(path)) + } + + #[test] + fn the_session_id_is_the_only_thing_substituted() { + assert_eq!( + tasks_dir("/var/tasks/{session}", "s-1", home("/home/agent")), + "/var/tasks/s-1", + "the placeholder resolves" + ); + // Everything either side of the placeholder is the consumer's string and + // is returned untouched — the engine knows one field, not a layout. + assert_eq!( + tasks_dir("/var/{session}/x/{session}", "s-1", home("/home/agent")), + "/var/s-1/x/s-1" + ); + assert_eq!( + tasks_dir("/var/tasks/fixed", "s-1", home("/home/agent")), + "/var/tasks/fixed" + ); + } + + #[test] + fn a_leading_tilde_expands_and_a_bare_one_does_not() { + // Shown able to fail in the direction that matters: without expansion the + // declared path reaches no directory, and a store that resolves to + // nothing reads exactly like a consumer with no work. + assert_eq!( + tasks_dir("~/.claude/tasks/{session}", "s-1", home("/home/agent")), + "/home/agent/.claude/tasks/s-1" + ); + // Only a LEADING `~/` is special. + assert_eq!( + tasks_dir("/var/~/{session}", "s-1", home("/home/agent")), + "/var/~/s-1" + ); + } + + #[test] + fn an_absent_home_leaves_the_template_alone() { + // Could-not-look rather than a guess: an unexpanded `~` names no + // directory, so the caller's `is_dir` check takes the honest arm instead + // of this function inventing a home. + assert_eq!( + tasks_dir("~/.claude/tasks/{session}", "s-1", None), + "~/.claude/tasks/s-1" + ); + assert_eq!( + tasks_dir("~/.claude/tasks/{session}", "s-1", home("")), + "~/.claude/tasks/s-1", + "an empty HOME is absent, not a root" + ); + } + /// The agent-context fields in the shape a real session writes them /// (measured 2026-08-13): they sit at the top level beside `sessionId`, /// except `model`, which sits on the message. Two lines, differing in From bd065fd334bf2e843d5377505cb13a553b029863 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 00:39:15 +0000 Subject: [PATCH 05/11] fix(git): the session task link is written, never committed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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/`. 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 --- .claude/.tasks | 1 - .gitignore | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) delete mode 120000 .claude/.tasks diff --git a/.claude/.tasks b/.claude/.tasks deleted file mode 120000 index 82c43c280..000000000 --- a/.claude/.tasks +++ /dev/null @@ -1 +0,0 @@ -/root/.claude/tasks/f1c4264a-538a-5e51-8b73-917e6ed8e791 \ No newline at end of file diff --git a/.gitignore b/.gitignore index a9d80422f..8fdf7bf45 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,12 @@ # Refreshed per turn by mise-tasks/stop-guard.sh from the Stop payload; committing # it would put a whole session's prose in the tree. /.claude/.transcript.jsonl +# Its sibling for the session task store (CLOUD-1376), derived beside it and +# refreshed from the same Stop payload. Committing this one leaks no prose — a +# symlink is a path — but it names ONE container's session by id, so every other +# checkout inherits a dangling pointer, and `doctor session` reads a dangling link +# as could-not-look forever. The engine writes it; the tree never carries it. +/.claude/.tasks # Per-user local Claude memory — never shared (personal overrides of CLAUDE.md). CLAUDE.local.md From 60d0bf046721dfe8f2676ae706ff68bfd28029e9 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 01:18:39 +0000 Subject: [PATCH 06/11] fix(policy): the launcher outlives the repair, so the two rows are declared again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 dda531f7 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 --- policy/harness-declared.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/policy/harness-declared.json b/policy/harness-declared.json index 0967ef424..322564926 100644 --- a/policy/harness-declared.json +++ b/policy/harness-declared.json @@ -1 +1,4 @@ -{} +{ + "stop-hook-git-check.sh": "CLOUD-1079", + "session-start-git-identity.sh": "CLOUD-1079" +} From 77d611d6420e2854f20086c4bfc76eaf682eb8c8 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 01:34:36 +0000 Subject: [PATCH 07/11] chore(surface): regenerate the derived artifacts for the new sub-verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- completions/batten.bash | 73 ++++++++++++++++++- completions/batten.fish | 59 ++++++++++----- completions/batten.zsh | 57 +++++++++++++++ crates/batten/src/doctor.rs | 8 +- .../it__snapshots__golden_json_schema.snap | 22 ++++++ man/batten-doctor-session.1 | 16 ++++ man/batten-doctor.1 | 3 + 7 files changed, 214 insertions(+), 24 deletions(-) create mode 100644 man/batten-doctor-session.1 diff --git a/completions/batten.bash b/completions/batten.bash index 4c6c488f5..3998021c3 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -292,12 +292,18 @@ _batten() { batten__subcmd__doctor,hooks) cmd="batten__subcmd__doctor__subcmd__hooks" ;; + batten__subcmd__doctor,session) + cmd="batten__subcmd__doctor__subcmd__session" + ;; batten__subcmd__doctor__subcmd__help,help) cmd="batten__subcmd__doctor__subcmd__help__subcmd__help" ;; batten__subcmd__doctor__subcmd__help,hooks) cmd="batten__subcmd__doctor__subcmd__help__subcmd__hooks" ;; + batten__subcmd__doctor__subcmd__help,session) + cmd="batten__subcmd__doctor__subcmd__help__subcmd__session" + ;; batten__subcmd__generate,completions) cmd="batten__subcmd__generate__subcmd__completions" ;; @@ -505,6 +511,9 @@ _batten() { batten__subcmd__help__subcmd__doctor,hooks) cmd="batten__subcmd__help__subcmd__doctor__subcmd__hooks" ;; + batten__subcmd__help__subcmd__doctor,session) + cmd="batten__subcmd__help__subcmd__doctor__subcmd__session" + ;; batten__subcmd__help__subcmd__generate,completions) cmd="batten__subcmd__help__subcmd__generate__subcmd__completions" ;; @@ -2544,7 +2553,7 @@ _batten() { return 0 ;; batten__subcmd__doctor) - opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help hooks help" + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help hooks session help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2574,7 +2583,7 @@ _batten() { return 0 ;; batten__subcmd__doctor__subcmd__help) - opts="hooks help" + opts="hooks session help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2615,6 +2624,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__doctor__subcmd__help__subcmd__session) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__doctor__subcmd__hooks) opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2645,6 +2668,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__doctor__subcmd__session) + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__enforce) opts="-J -q -v -y -h --rule --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then @@ -3418,7 +3471,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__doctor) - opts="hooks" + opts="hooks session" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3445,6 +3498,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__doctor__subcmd__session) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__enforce) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index fee8a5e2c..cad2e3903 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -579,30 +579,31 @@ complete -c batten -n "__fish_batten_using_subcommand spec" -l no-color -d 'Neve complete -c batten -n "__fish_batten_using_subcommand spec" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand spec" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand spec" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from hooks session help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from hooks" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -625,7 +626,30 @@ complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from hooks" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from hooks" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from hooks" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand init" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' @@ -2896,6 +2920,7 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "lint" -d 'Report policy smells in batten.toml (any smell is a violation)' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from lint" -f -a "brief" -d 'Check a delegation brief against the handoff schema (any missing section is a violation)' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "completions" -d 'Emit the shell completion script for one shell' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "hooks" -d 'Emit one harness\'s hook registrations, on stdout' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "man" -d 'Emit the roff man page for one command, on stdout' diff --git a/completions/batten.zsh b/completions/batten.zsh index e4c578d97..24b4d48b8 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -968,6 +968,37 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(session) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'-J[Emit byte-stable JSON instead of pointer lines]' \ +'--json[Emit byte-stable JSON instead of pointer lines]' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__doctor__subcmd__help_commands" \ @@ -984,6 +1015,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(session) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -4981,6 +5016,10 @@ _arguments "${_arguments_options[@]}" : \ (hooks) _arguments "${_arguments_options[@]}" : \ && ret=0 +;; +(session) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 ;; esac ;; @@ -6069,6 +6108,7 @@ _batten__subcmd__design__subcmd__help__subcmd__help_commands() { _batten__subcmd__doctor_commands() { local commands; commands=( 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ +'session:Diagnose whether this session has declared work it has not finished' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten doctor commands' commands "$@" @@ -6077,6 +6117,7 @@ _batten__subcmd__doctor_commands() { _batten__subcmd__doctor__subcmd__help_commands() { local commands; commands=( 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ +'session:Diagnose whether this session has declared work it has not finished' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten doctor help commands' commands "$@" @@ -6091,11 +6132,21 @@ _batten__subcmd__doctor__subcmd__help__subcmd__hooks_commands() { local commands; commands=() _describe -t commands 'batten doctor help hooks commands' commands "$@" } +(( $+functions[_batten__subcmd__doctor__subcmd__help__subcmd__session_commands] )) || +_batten__subcmd__doctor__subcmd__help__subcmd__session_commands() { + local commands; commands=() + _describe -t commands 'batten doctor help session commands' commands "$@" +} (( $+functions[_batten__subcmd__doctor__subcmd__hooks_commands] )) || _batten__subcmd__doctor__subcmd__hooks_commands() { local commands; commands=() _describe -t commands 'batten doctor hooks commands' commands "$@" } +(( $+functions[_batten__subcmd__doctor__subcmd__session_commands] )) || +_batten__subcmd__doctor__subcmd__session_commands() { + local commands; commands=() + _describe -t commands 'batten doctor session commands' commands "$@" +} (( $+functions[_batten__subcmd__enforce_commands] )) || _batten__subcmd__enforce_commands() { local commands; commands=() @@ -6399,6 +6450,7 @@ _batten__subcmd__help__subcmd__design__subcmd__audit_commands() { _batten__subcmd__help__subcmd__doctor_commands() { local commands; commands=( 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ +'session:Diagnose whether this session has declared work it has not finished' \ ) _describe -t commands 'batten help doctor commands' commands "$@" } @@ -6407,6 +6459,11 @@ _batten__subcmd__help__subcmd__doctor__subcmd__hooks_commands() { local commands; commands=() _describe -t commands 'batten help doctor hooks commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__doctor__subcmd__session_commands] )) || +_batten__subcmd__help__subcmd__doctor__subcmd__session_commands() { + local commands; commands=() + _describe -t commands 'batten help doctor session commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__enforce_commands] )) || _batten__subcmd__help__subcmd__enforce_commands() { local commands; commands=() diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index 326ef1322..bfba5b04b 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -1244,10 +1244,10 @@ pub fn diagnose_session(dir: &Path) -> SessionReport { return unreadable; }; total += 1; - if task.get("status").and_then(serde_json::Value::as_str) != Some("completed") { - if let Some(id) = task.get("id").and_then(serde_json::Value::as_str) { - ids.push(id.to_owned()); - } + if task.get("status").and_then(serde_json::Value::as_str) != Some("completed") + && let Some(id) = task.get("id").and_then(serde_json::Value::as_str) + { + ids.push(id.to_owned()); } } // Byte-stable output (§6): directory order is the filesystem's, and a report diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 710677ef6..5e76e9195 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -852,6 +852,24 @@ expression: stdout_of(&output) } ], "subcommands": [] + }, + { + "path": "doctor session", + "id": "doctor.session", + "about": "Diagnose whether this session has declared work it has not finished", + "effect": "read", + "data_channel": true, + "flags": [ + { + "name": "json", + "short": "J", + "long": "json", + "takes_value": false, + "positional": false, + "help": "Emit byte-stable JSON instead of pointer lines" + } + ], + "subcommands": [] } ] }, @@ -2422,6 +2440,10 @@ expression: stdout_of(&output) "id": "doctor.hooks", "path": "doctor hooks" }, + { + "id": "doctor.session", + "path": "doctor session" + }, { "id": "generate", "path": "generate" diff --git a/man/batten-doctor-session.1 b/man/batten-doctor-session.1 new file mode 100644 index 000000000..79073c068 --- /dev/null +++ b/man/batten-doctor-session.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-doctor-session 1 batten +.SH NAME +batten\-doctor\-session \- Diagnose whether this session has declared work it has not finished +.SH SYNOPSIS +\fBbatten doctor session\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Diagnose whether this session has declared work it has not finished +.SH OPTIONS +.TP +\fB\-J\fR, \fB\-\-json\fR +Emit byte\-stable JSON instead of pointer lines +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-doctor.1 b/man/batten-doctor.1 index ef20a3bf2..8d4302e8d 100644 --- a/man/batten-doctor.1 +++ b/man/batten-doctor.1 @@ -19,5 +19,8 @@ Print help batten\-doctor\-hooks(1) Diagnose whether batten is wired on every hook surface of every harness .TP +batten\-doctor\-session(1) +Diagnose whether this session has declared work it has not finished +.TP batten\-doctor\-help(1) Print this message or the help of the given subcommand(s) From 76ab6ff4c51a8c6dbc6c03ffc465863989c4a60f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 01:50:20 +0000 Subject: [PATCH 08/11] chore(spec): state the new sub-verb in the two lists that must be stated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- crates/batten/src/spec.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 77440ca1e..56d32e89b 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -451,6 +451,12 @@ mod tests { // spawn nothing: the sub-verb compares each harness's wiring // against a derivation computed in-process. "doctor hooks".to_owned(), + // `read`, and structurally, for the sub-verb above's reason: it + // opens the session's own task store through a link the engine + // parked and counts what is not `completed`. It spawns nothing, + // writes nothing, and reaches no network — the store is a + // directory of small JSON files (CLOUD-1376). + "doctor session".to_owned(), "generate".to_owned(), "generate completions".to_owned(), // §11's third derivation (CLOUD-62): the hook wiring a host @@ -679,6 +685,7 @@ mod tests { "design audit".to_owned(), "doctor".to_owned(), "doctor hooks".to_owned(), + "doctor session".to_owned(), "enforce".to_owned(), "exec".to_owned(), // The schema is emitted by `generate`, not `config`: it is a From a5e4334ebab4856e21fcadee04dc7b47e4f60f9d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 02:09:00 +0000 Subject: [PATCH 09/11] test(pointer-only): the session verb reads real prose, then declines to print it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- crates/batten/tests/it/pointer_only.rs | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index c9d28e281..1caa6d063 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -100,6 +100,10 @@ const CONTENT: &[Canary] = &[ tag: "spoken", source: "free text inside a completed-session transcript", }, + Canary { + tag: "declared", + source: "the subject line of a task in the session's own store", + }, Canary { tag: "childout", source: "a wrapped or configured child's own stdout", @@ -246,6 +250,7 @@ fn authority(spawning: bool) -> String { \n\ [transcript]\n\ path = \"transcript.jsonl\"\n\ + tasks = \"/nonexistent/{{session}}\"\n\ \n\ [epoch]\n\ tracked = [\"batten.toml\"]\n", @@ -313,6 +318,28 @@ impl Corpus { ), ) .file("counted.txt", &format!("{}\n", canary("counted"))) + // The session's own task store, where the engine parks its link + // (CLOUD-1376). A real directory rather than a symlink: `read_dir` + // follows either, so the reading under test is identical, and this + // keeps the corpus from depending on how a platform spells a link. + // + // IT MUST BE A LIVE READING, NOT AN EXEMPTION. Without a store + // `doctor session` answers could-not-look — exit 3 — and this census + // refuses that as evidence by construction: a verb that failed + // internally proves nothing by not emitting content. Seeding it is + // what makes `doctor session` actually read prose and then decline to + // print it, which is the only version of this assertion worth having. + // + // The subject is a CONTENT canary because that is exactly what it is: + // free text an agent wrote. The verb may emit the id `1` and the + // counts, and must never emit the line beside them. + .file( + ".tasks/1.json", + &format!( + "{{\n \"id\": \"1\",\n \"subject\": \"{}\",\n \"status\": \"pending\"\n}}\n", + canary("declared"), + ), + ) // A published schema for `config deprecations` to compare against, // carrying a CONTENT canary in a description. The verb must name the // removed key and never the schema body, so a run that echoed what it @@ -981,6 +1008,24 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // THE SESSION'S OWN DECLARED WORK (CLOUD-1376), and its content class is the + // reason it belongs here rather than being obvious. What this verb reads is a + // task store whose members carry a `subject` and a `description` — free prose + // an agent wrote, which is exactly the CONTENT class above. What it emits is + // two integers and a list of ids. + // + // So the pointer-only promise is load-bearing rather than incidental: an id + // sends a reader to the task, and a subject line would hand the session its + // own prose back as input — the mirror a restatement can clear, which is the + // defect `finding-sink-check` documents at length. The compiled-binary tier + // asserts the negative directly, and this census is what stops a later + // revision widening the output without answering the question. + Verb { + path: "doctor session", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, Verb { // `-n` so the census cannot author into the corpus. It changes nothing // about what is emitted here: the corpus already carries a `batten.toml`, From bddf0b99d84c047519dc71b8dc10b08be1f48c48 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 02:28:46 +0000 Subject: [PATCH 10/11] style(transcript): the test helper stops hiding which cases are about an absent home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- crates/batten/src/transcript.rs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/batten/src/transcript.rs b/crates/batten/src/transcript.rs index e47f59940..5cbb05165 100644 --- a/crates/batten/src/transcript.rs +++ b/crates/batten/src/transcript.rs @@ -1249,25 +1249,35 @@ mod tests { parse(SAMPLE, "t.jsonl").expect("parses") } - fn home(path: &str) -> Option<&std::ffi::OsStr> { - Some(std::ffi::OsStr::new(path)) + /// The home a host would hand over, in the shape the resolver takes it. + /// + /// Returns the value rather than an `Option` of it: every call here supplies + /// a home, so wrapping inside the helper would hide which cases are ABOUT an + /// absent home — and that is the arm `an_absent_home_leaves_the_tilde_alone` + /// exists to pin. `Some` at the call site keeps that visible. + fn home(path: &str) -> &std::ffi::OsStr { + std::ffi::OsStr::new(path) } #[test] fn the_session_id_is_the_only_thing_substituted() { assert_eq!( - tasks_dir("/var/tasks/{session}", "s-1", home("/home/agent")), + tasks_dir("/var/tasks/{session}", "s-1", Some(home("/home/agent"))), "/var/tasks/s-1", "the placeholder resolves" ); // Everything either side of the placeholder is the consumer's string and // is returned untouched — the engine knows one field, not a layout. assert_eq!( - tasks_dir("/var/{session}/x/{session}", "s-1", home("/home/agent")), + tasks_dir( + "/var/{session}/x/{session}", + "s-1", + Some(home("/home/agent")) + ), "/var/s-1/x/s-1" ); assert_eq!( - tasks_dir("/var/tasks/fixed", "s-1", home("/home/agent")), + tasks_dir("/var/tasks/fixed", "s-1", Some(home("/home/agent"))), "/var/tasks/fixed" ); } @@ -1278,12 +1288,16 @@ mod tests { // declared path reaches no directory, and a store that resolves to // nothing reads exactly like a consumer with no work. assert_eq!( - tasks_dir("~/.claude/tasks/{session}", "s-1", home("/home/agent")), + tasks_dir( + "~/.claude/tasks/{session}", + "s-1", + Some(home("/home/agent")) + ), "/home/agent/.claude/tasks/s-1" ); // Only a LEADING `~/` is special. assert_eq!( - tasks_dir("/var/~/{session}", "s-1", home("/home/agent")), + tasks_dir("/var/~/{session}", "s-1", Some(home("/home/agent"))), "/var/~/s-1" ); } @@ -1298,7 +1312,7 @@ mod tests { "~/.claude/tasks/s-1" ); assert_eq!( - tasks_dir("~/.claude/tasks/{session}", "s-1", home("")), + tasks_dir("~/.claude/tasks/{session}", "s-1", Some(home(""))), "~/.claude/tasks/s-1", "an empty HOME is absent, not a root" ); From 498642c46a895fcfdf03a2a40a12c7b43bc6fb40 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 3 Sep 2026 03:33:35 +0000 Subject: [PATCH 11/11] fix(transcript): the tilde expands by substitution, not by a platform path join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/batten/src/transcript.rs | 34 +++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/batten/src/transcript.rs b/crates/batten/src/transcript.rs index 5cbb05165..2fd4aa712 100644 --- a/crates/batten/src/transcript.rs +++ b/crates/batten/src/transcript.rs @@ -1190,10 +1190,17 @@ pub fn tasks_dir(template: &str, session: &str, home: Option<&std::ffi::OsStr>) return substituted; }; match home { - Some(home) if !home.is_empty() => std::path::Path::new(home) - .join(rest) - .to_string_lossy() - .into_owned(), + // SUBSTITUTION, NEVER A PATH JOIN, and Windows CI is what proved the + // difference. `Path::join` inserts the PLATFORM's separator, so a + // template written `~/.claude/tasks/{session}` came back as + // `/home/agent\.claude/tasks/s-1` there — the engine rewriting a + // separator the consumer chose. + // + // The template is the consumer's string and its shape is theirs: this + // function knows one placeholder and one prefix, and everything either + // side of them is returned exactly as written (rule 1, the same reason + // the layout is declared rather than derived). + Some(home) if !home.is_empty() => format!("{}/{rest}", home.to_string_lossy()), _ => substituted, } } @@ -1300,6 +1307,25 @@ mod tests { tasks_dir("/var/~/{session}", "s-1", Some(home("/home/agent"))), "/var/~/s-1" ); + // THE SEPARATOR IS THE CONSUMER'S, AND WINDOWS CI IS WHY THIS ARM EXISTS. + // An earlier revision expanded through `Path::join`, which inserts the + // PLATFORM's separator — so this same case returned + // `/home/agent\.claude/tasks/s-1` on the Windows runner while passing + // here. The assertion above could not see it, because the two platforms + // disagreed rather than the logic being wrong. + // + // A Windows-shaped home makes the property visible on EVERY platform: the + // home comes back exactly as given and the template's own `/` survives, + // so nothing in this function rewrites a separator either side chose. + assert_eq!( + tasks_dir( + "~/.claude/tasks/{session}", + "s-1", + Some(home("D:\\Users\\agent")) + ), + "D:\\Users\\agent/.claude/tasks/s-1", + "the home is returned verbatim and the template keeps its own separator" + ); } #[test]