diff --git a/.claude-config/bin/README.md b/.claude-config/bin/README.md index cff5c6a..a7b8a63 100644 --- a/.claude-config/bin/README.md +++ b/.claude-config/bin/README.md @@ -593,6 +593,21 @@ thread gets the foreman boot. So the rules ride in what fires. a lane and starts nothing. `context-watch.sh` caches each session's `lane-class` answer in its run directory (30 minutes; a failed ask, 2 minutes) and cuts a hung classifier at 2 seconds. +- The `Team asks:` line (`session-briefing.sh`, `team_ask_line`, after the Reminders + block): on a box where a team runs a local ask router (`~/.local/bin/team-ask`), the + briefing prints the one line `team-ask pending --brief` returns, naming the asks from a + teammate's agent that still wait on this box's owner. The reader reads local state only + (no network) and runs under `hook_timeout` at 3 s with a 1 s kill. Quiet when the tool is + absent (every copy with no router), when `~/.config/team-ask/disabled` exists (the + router's installer writes it when the box is turned off on purpose), when the session's + folder is the `~/team-asks` workspace (the logical or the resolved path; the asks are the + subject of that thread), when the reader prints nothing, and for a lane (the lane gate + exits first). Never silent when broken: a dangling link, a tool that is not executable, a + failing or timed-out reader, or a host with no `timeout` command each print a line naming + `team-ask pending`, because an empty line would read as "nobody is waiting". The line is + one line, control characters stripped, 400 characters at most. This hook has no resume + short form, so a resume or compact prints it with the rest of the briefing. The + matching ruling for the thread such an ask opens is in `CONTEXT/standing-orders.md`. - Per-thread work keys and the leftover-stamp scan (`hooks/hooklib.sh`): under T3 Code the work key is `t3-`, stable across compaction and resume, so two threads in one directory keep separate stamps and work logs. @@ -606,7 +621,8 @@ thread gets the foreman boot. So the rules ride in what fires. Tests: `tests/lane-class-test.sh`, `tests/await-test.sh`, `tests/safe-pkill-test.sh`, `tests/lane-rules-test.sh`, `tests/session-briefing-lane-test.sh`, -`tests/session-briefing-scope-test.sh`, `tests/work-session-key-test.sh`, +`tests/session-briefing-scope-test.sh`, `tests/session-briefing-team-ask-test.sh`, +`tests/work-session-key-test.sh`, `tests/orphan-gate-test.sh`, `hooks/context-watch-selftest.sh`, `hooks/foreman-context-selftest.sh` and `tests/post-compact-resume-test.sh`. diff --git a/.claude-config/bin/tests/session-briefing-team-ask-test.sh b/.claude-config/bin/tests/session-briefing-team-ask-test.sh new file mode 100644 index 0000000..eb6b192 --- /dev/null +++ b/.claude-config/bin/tests/session-briefing-team-ask-test.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# session-briefing-team-ask-test.sh: behavior tests for the "Team asks:" line in the boot +# briefing (hooks/session-briefing.sh team_ask_line). +# +# Why pin these: on a team's boxes, an ask from a teammate's agent that needs the owner +# opens an investigation thread on the owner's box. That thread is the first surface; this +# line is the second, and the one that does not depend on the thread having opened. An ask +# whose notice was only a log line reached nobody. So the line must say what is waiting, and +# it must NEVER turn a broken reader into silence: "nothing printed" reads as "nobody is +# waiting on you". And it must not hang boot, speak to a lane, repeat itself inside the +# Team asks workspace, or nag a box that was turned off. A copy with no router installed +# (every copy that is not a team box) must see no change at all. +# +# Cases: tool absent (quiet), the provider's line shown as printed, a line without the label +# (labelled), only the first line, control characters stripped, nothing printed (quiet), a hung +# provider (the timeout line within the cap), one that ignores TERM (killed one grace second +# later), a failing provider (partial output not shown), an old CLI without `pending`, a +# dangling link, a file that is not executable, no timeout command, the provider's argv, the +# disable marker (quiet, provider never run), the Team asks workspace as the working folder +# (itself, a subfolder, a symlink to it; provider never run), lanes (t3-lane and agent-lane; +# provider never run), a resumed session (this hook has no short form, so the line prints +# there too), and the banner's shape (the line follows Reminders, inside the rule, one line, +# far under the 8192-byte budget). +# +# Every path the hook reads is under a temp dir (HOME, OPS_DIR, CLAUDE_CONFIG_DIR, OPS_STATE_DIR); +# the provider is a stub that logs each run. The suite carries the sandbox guard because +# session-briefing.sh writes and moves state files. +# +# Usage: session-briefing-team-ask-test.sh (exit 0 all pass, 1 otherwise) +# Shell snippets inside the stubs are intentionally literal test data. +# shellcheck disable=SC2016 +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)/../sandbox-guard.sh" && sandbox_guard || exit 97 +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd -P)" +HOOK="$HERE/../../hooks/session-briefing.sh" +[ -f "$HOOK" ] || { echo "FAIL: $HOOK missing"; exit 1; } +command -v jq >/dev/null || { echo "FAIL: jq required (the hook's own parser)"; exit 1; } +TMO="$(command -v gnutimeout || command -v timeout || true)" +[ -n "$TMO" ] || { echo "SKIP: no timeout command (the hook's reader cap)"; exit 0; } + +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +pass=0; fail=0 +ok() { pass=$((pass+1)); echo "PASS: $1"; } +bad() { fail=$((fail+1)); echo "FAIL: $1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; } + +mkdir -p "$T/home/.local/bin" "$T/home/.config/team-ask" "$T/home/team-asks/sub" "$T/ops/CONTEXT" "$T/cfg/projects" "$T/state" "$T/work" +printf '# Core\n' > "$T/ops/CONTEXT/core.md" +TOOL="$T/home/.local/bin/team-ask" +CALLS="$T/provider-calls.log" +LANE_THREAD='thread:delegated-task:command%3Amcp%3Aabc%3Adelegate-task%3Ax' +STARTUP='{"hook_event_name":"SessionStart","source":"startup","session_id":"sess-team-ask-test"}' +LINE='Team asks: 2 need you · Teammate-A TKT-0001 x3 (1d, thread open) · Teammate-B TKT-0002 (5h, thread FAILED: guard_unproven)' + +# provider : a stub team-ask whose run is logged (its argv, one line per run) and then +# does . The log is how a case proves the provider was, or was not, started. +provider() { + rm -f "$TOOL" + { printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> %q\n' "$CALLS"; printf '%s\n' "$1"; } > "$TOOL" + chmod +x "$TOOL" +} +ran() { [ -s "$CALLS" ]; } + +# brief [cwd] [payload] [VAR=value ...]: run the hook hermetically; output in $T/out, rc in $rc. +brief() { + local cwd="${1:-$T/work}" payload="${2:-$STARTUP}" + if [ $# -ge 2 ]; then shift 2; else shift $#; fi + rm -f "$CALLS" + ( cd "$cwd" && printf '%s' "$payload" | env -u TMUX -u T3CODE_THREAD_ID -u CLAUDE_CODE_SESSION_ID -u OPS_LANE_CLASS \ + -u OPS_STATE_DIR -u OPS_ORPHAN_SCAN -u T3CODE_HOME \ + HOME="$T/home" OPS_DIR="$T/ops" CLAUDE_CONFIG_DIR="$T/cfg" OPS_STATE_DIR="$T/state" LANE_CLASS_NO_DB=1 "$@" \ + "$TMO" -k 2 60 bash "${HOOK_UNDER_TEST:-$HOOK}" >"$T/out" 2>"$T/err" ) # a hook that hangs fails its case, not the suite + rc=$? +} +lines() { grep '^ Team asks:' "$T/out"; } +nlines() { grep -c '^ Team asks:' "$T/out"; } + +silent() { #