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 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 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/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/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..bfba5b04b 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") + && 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..ea6212de9 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 +12665,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/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 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..2fd4aa712 100644 --- a/crates/batten/src/transcript.rs +++ b/crates/batten/src/transcript.rs @@ -1130,6 +1130,79 @@ 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). +/// +/// A LEADING `~/` EXPANDS, and that is a correctness requirement rather than a +/// convenience. These stores live under the launcher's home, no committed value +/// may name a container's absolute home path, and an unexpanded `~` resolves to +/// nothing — a declaration that reaches no directory reads exactly like a +/// consumer with no work, which is the false clean this whole surface exists to +/// refuse. Only a leading `~/` is special; a `~` anywhere else is an ordinary +/// character, and an absent `home` leaves the template alone so the caller's +/// `is_dir` check takes the could-not-look arm. +/// +/// `home` is a PARAMETER rather than a read of `HOME` inside, so this stays a +/// pure function of its inputs: reading ambient state here would make the unit +/// case set a process-wide variable to assert anything, and a test that mutates +/// the environment is a test other tests race with. +#[must_use] +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 { + // 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, + } } /// Validate the table at load, the way every other config table is. @@ -1183,6 +1256,94 @@ mod tests { parse(SAMPLE, "t.jsonl").expect("parses") } + /// 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", 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", + Some(home("/home/agent")) + ), + "/var/s-1/x/s-1" + ); + assert_eq!( + tasks_dir("/var/tasks/fixed", "s-1", Some(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", + Some(home("/home/agent")) + ), + "/home/agent/.claude/tasks/s-1" + ); + // Only a LEADING `~/` is special. + assert_eq!( + 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] + 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", Some(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 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/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`, 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)); +} 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) 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" +} 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