diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 15e16a8..415ade6 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -73,3 +73,5 @@ jobs: echo "$dir" >> "$GITHUB_PATH" - name: Plugin smoke run: OMP_SMOKE_REQUIRED=1 bun omp-port/smoke-plugin.mjs + - name: Worktree audit against live omp + run: OMP_AUDIT_LIVE_REQUIRED=1 bash omp-port/test-worktree-audit.sh diff --git a/omp-port/patches/worktree-audit-omp.patch b/omp-port/patches/worktree-audit-omp.patch index 12f4218..05bbb82 100644 --- a/omp-port/patches/worktree-audit-omp.patch +++ b/omp-port/patches/worktree-audit-omp.patch @@ -10,8 +10,20 @@ # root, checked first, - under home, ---- otherwise, / \ : mapped to -) # - scan only the buckets of this repo's worktrees, never the whole store, with grep -F, so # ripgrep is not required and a path with [ or spaces matches literally -# - fail closed: a store that cannot be found or read makes LAST_CHAT "?" and holds the row at -# verify-recent-chat, the bucket playbooks/worktree-cleanup.md already names, never `safe` +# - scan each linked worktree's subdirectory buckets too, found by the bucket-name prefix, since +# omp files a session started in /src under that cwd's own bucket +# - scan those buckets in every store on disk, not only the one the audit's environment names: +# the default agent dir, each profile's, PI_CODING_AGENT_DIR, and the XDG data and state +# roots, so a session launched with --profile still counts +# - read each store's custom-session-files, where omp lists the absolute path of every +# transcript written outside the bucket store (--session-dir, PI_CODING_AGENT_SESSION_DIR, +# --resume of a moved file), and scan those transcripts and their subagent directories, +# skipping the bucket transcripts omp also lists under a symlinked home, which the bucket +# scan already bounds to this repo's worktrees +# - fail closed: a store that cannot be found or read, an index that cannot be read, or a +# listed transcript behind a directory the audit cannot search makes LAST_CHAT "?" and holds +# the row at verify-recent-chat, the bucket playbooks/worktree-cleanup.md already names, +# never `safe` # - fail closed the same way when a .env file omp reads (each worktree's, the agent dir's, the # config root's, home's) sets a key that moves the store, OMP_ spellings included, rather # than replaying omp's .env loader @@ -54,7 +66,7 @@ if [ -z "$base" ]; then for cand in "$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" origin/main origin/master; do -@@ -55,33 +60,105 @@ +@@ -55,33 +60,164 @@ stat_fmt=(-f '%m %N'); fmt_day() { date -r "$1" '+%Y-%m-%d'; } fi @@ -89,9 +101,10 @@ +# PI_PROFILE) under ~/${PI_CONFIG_DIR:-.omp}, PI_CODING_AGENT_DIR when no +# profile is set, or an existing XDG_DATA_HOME/omp when the agent dir is the +# default, and it holds one bucket per canonical cwd. Only the buckets of this -+# repo's worktrees are scanned; walking every bucket would scale with total -+# session history. A store that cannot be read makes LAST_CHAT "?" and holds -+# the row at verify-recent-chat instead of reading as no chat at all. ++# repo's worktrees and their subdirectories are scanned; walking every bucket ++# would scale with total session history. A store that cannot be read makes ++# LAST_CHAT "?" and holds the row at verify-recent-chat instead of reading as ++# no chat at all. +# omp also fills each unset variable from the .env in the cwd it launched in, +# then the agent dir's, the config root's and home's, reading an OMP_ key there +# as its PI_ alias, while the profile comes from the environment alone. The @@ -118,31 +131,83 @@ + else printf '%s' "--$(dash "${1#/}")--"; fi +} +session_dirs=() -+moved="" ++dotenvs=() ++config_root="$HOME/${PI_CONFIG_DIR:-.omp}" +if [ -n "${PI_CODING_AGENT_SESSION_DIR:-}" ]; then + sessions=$PI_CODING_AGENT_SESSION_DIR + session_dirs=("$sessions/") +else + profile=$(printf '%s' "${OMP_PROFILE-${PI_PROFILE-}}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + [ "$profile" = default ] && profile="" -+ config="$HOME/${PI_CONFIG_DIR:-.omp}${profile:+/profiles/$profile}" ++ config="$config_root${profile:+/profiles/$profile}" + agent="$config/agent" + [ -z "$profile" ] && [ -n "${PI_CODING_AGENT_DIR:-}" ] && agent=$PI_CODING_AGENT_DIR + data=$agent + xdg="${XDG_DATA_HOME:-}/omp${profile:+/profiles/$profile}" + [ "$agent" = "$config/agent" ] && [ -n "${XDG_DATA_HOME:-}" ] && [ -e "$xdg" ] && data=$xdg + sessions="$data/sessions" -+ home_root=$(canon "$HOME") -+ temp_root=$(canon "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}") + dotenvs=("$agent/.env" "$config/.env" "$HOME/.env") -+ while IFS= read -r path; do -+ dir="$sessions/$(bucket_name "$(canon "$path")")" -+ [ -d "$dir" ] && session_dirs+=("$dir/") -+ dotenvs+=("$path/.env") -+ done < <(printf '%s\n' "$worktree_records" | awk '/^worktree /{sub(/^worktree /, ""); print}') ++fi ++# A session launched with --profile, --session-dir or another environment is ++# not in that store. Every store under the config root and the XDG roots counts ++# too, and each one's custom-session-files lists the absolute path of every ++# transcript omp wrote outside the bucket store. Precedence does not matter ++# for these, since a transcript only counts when it names the worktree. ++home_root=$(canon "$HOME") ++temp_root=$(canon "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}") ++roots=("$config_root/agent" "$config_root"/profiles/*/agent) ++for x in "${XDG_DATA_HOME:-}" "${XDG_STATE_HOME:-}"; do ++ [ -n "$x" ] && roots+=("$x/omp" "$x"/omp/profiles/*) ++done ++[ -n "${PI_CODING_AGENT_DIR:-}" ] && roots+=("$PI_CODING_AGENT_DIR") ++indexes=() ++stores=$'\n' ++for root in "${roots[@]}"; do ++ [ -d "$root/custom-session-files" ] && indexes+=("$root/custom-session-files") ++ [ -d "$root/sessions" ] && stores+="$(canon "$root/sessions")"$'\n' ++done ++while IFS= read -r path; do ++ cpath=$(canon "$path") ++ own=$(bucket_name "$cpath") ++ # A session started in a subdirectory has that subdirectory's bucket, the ++ # worktree's name plus "-" ahead of any closing "--". A transcript ++ # still has to name the worktree path, so a bucket that only shares the ++ # prefix costs time, never a match. The main worktree is skipped, since a ++ # repo at $HOME would make this the whole store. ++ sub=$(bucket_name "$cpath/*") ++ for root in "${roots[@]}"; do ++ [ -d "$root/sessions/$own" ] && session_dirs+=("$root/sessions/$own/") ++ [ "$path" = "$main_wt" ] && continue ++ for dir in "$root/sessions/${sub%\**}"*"${sub##*\*}"; do ++ [ -d "$dir" ] && session_dirs+=("$dir/") ++ done ++ done ++ dotenvs+=("$path/.env") ++done < <(printf '%s\n' "$worktree_records" | awk '/^worktree /{sub(/^worktree /, ""); print}') ++# Under a symlinked home omp lists every bucket transcript too, and the bucket ++# scan already covers those. They share one parent, so it is canonicalized ++# once rather than once per transcript. Any other listed transcript that is ++# gone holds no chat, but one behind a directory the audit cannot search may ++# still exist. Its subagents sit in the directory named after it. ++listed=$([ "${#indexes[@]}" -eq 0 ] || grep -rhs '' -- "${indexes[@]}") ++index_status=$? ++seen="" store="" closed="" ++while IFS= read -r f; do ++ [ "${f%/*/*}" = "$seen" ] || { seen=${f%/*/*}; store=$(canon "$seen"); } ++ case "$stores" in *$'\n'"$store"$'\n'*) continue ;; esac ++ if [ ! -f "$f" ]; then ++ dir=${f%/*} ++ while [ ! -e "$dir" ] && [ "$dir" != "${dir%/*}" ]; do dir=${dir%/*}; done ++ [ -d "$dir" ] && [ ! -x "$dir" ] && closed=$dir ++ continue ++ fi ++ session_dirs+=("$f") ++ [ -d "${f%.jsonl}" ] && session_dirs+=("${f%.jsonl}/") ++done <<<"$listed" ++moved="" ++[ -n "${PI_CODING_AGENT_SESSION_DIR:-}" ] || + moved=$(grep -lsE '^[[:space:]]*(export[[:space:]]+)?((PI|OMP)_(CODING_AGENT_SESSION_DIR|CODING_AGENT_DIR|CONFIG_DIR)|XDG_DATA_HOME)[[:space:]]*[=:]' \ + -- "${dotenvs[@]}") -+fi +chat_lookup=ok +if [ -n "$moved" ]; then + chat_lookup=failed @@ -150,6 +215,12 @@ +elif [ ! -d "$sessions" ]; then + chat_lookup=failed + echo "warn: no omp session store at $sessions; LAST_CHAT is ? and rows hold at verify-recent-chat" >&2 ++elif [ "$index_status" -gt 1 ]; then ++ chat_lookup=failed ++ echo "warn: cannot read an omp custom-session-files index; LAST_CHAT is ? and rows hold at verify-recent-chat" >&2 ++elif [ -n "$closed" ]; then ++ chat_lookup=failed ++ echo "warn: cannot search $closed for a listed omp transcript; LAST_CHAT is ? and rows hold at verify-recent-chat" >&2 +fi now=$(date +%s) @@ -174,7 +245,7 @@ head_ts=$(git -C "$wt" log -1 --format='%ct' HEAD 2>/dev/null || echo 0) age=$([ "$head_ts" -gt 0 ] 2>/dev/null && echo "$(( (now - head_ts) / 86400 ))d" || echo "?") -@@ -89,12 +166,12 @@ +@@ -89,12 +225,12 @@ # real signal; merge-base only catches fast-forward/rebase merges. git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no @@ -193,7 +264,7 @@ branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") if [ -z "$branch" ]; then remote=detached -@@ -102,33 +179,57 @@ +@@ -102,33 +238,57 @@ [ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \ && remote=pushed \ || remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)" diff --git a/omp-port/test-worktree-audit.sh b/omp-port/test-worktree-audit.sh index ab5732f..11d4d04 100755 --- a/omp-port/test-worktree-audit.sh +++ b/omp-port/test-worktree-audit.sh @@ -9,8 +9,8 @@ # Builds a throwaway repo with a local bare origin, a fake gh and origin CLI, and fixture omp # session stores, then runs the audit under each store layout omp resolves. Needs git and jq. It # never reaches the network, the real gh, or the real session store. When omp is on PATH it also -# asks that omp, under the fixture HOME and with no model turn, which transcript it would write; -# without omp that probe prints SKIPPED. +# asks that omp, under the fixture HOME and with no model turn, which transcript it would write. +# Without omp that probe prints SKIPPED, or fails when OMP_AUDIT_LIVE_REQUIRED=1. set -euo pipefail PORT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) @@ -34,7 +34,8 @@ ln -s "real home" "$FIXTURE/home" export HOME="$FIXTURE/home" TMPDIR="$FIXTURE/home/scratch" CT="$CH/scratch" unset TMP TEMP CDPATH GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE \ - PI_CODING_AGENT_SESSION_DIR PI_CODING_AGENT_DIR PI_CONFIG_DIR OMP_PROFILE PI_PROFILE XDG_DATA_HOME + PI_CODING_AGENT_SESSION_DIR PI_CODING_AGENT_DIR PI_CONFIG_DIR OMP_PROFILE PI_PROFILE XDG_DATA_HOME \ + XDG_STATE_HOME export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 export GIT_AUTHOR_NAME=audit GIT_AUTHOR_EMAIL=audit@example.invalid export GIT_COMMITTER_NAME=audit GIT_COMMITTER_EMAIL=audit@example.invalid @@ -64,7 +65,7 @@ commit() { git -C "$1" rev-parse HEAD } for name in open draft closed-pushed closed-unpushed squash local-only untracked ignored tracked \ - locked broken sibling unrelated plain; do + locked broken sibling unrelated plain subdir; do wt "$name" "$CH/$name" done RECENT='recent [one].worktree' @@ -72,6 +73,7 @@ wt recent "$CH/$RECENT" wt nested "$CH/nest/deep:wt" wt temp "$CT/temp:wt" wt absolute "$FIXTURE/abs:wt" +wt sub-absolute "$FIXTURE/sub:abs" g "$REPO" worktree add -q --detach "$CH/detached" main OPEN_HEAD=$(git -C "$CH/open" rev-parse HEAD) @@ -96,7 +98,7 @@ printf 'log\n' >"$CH/ignored/run.log" printf 'edit\n' >>"$CH/tracked/tracked" g "$REPO" worktree lock --reason 'keep this worktree' "$CH/locked" printf 'gitdir: %s/missing\n' "$FIXTURE" >"$CH/broken/.git" -ROWS=19 +ROWS=21 # A closed PR on a reused branch name sits first, so the audit has to prefer the PR whose head is # HEAD. gh reports uppercase states; the audit lowercases them. @@ -153,6 +155,11 @@ printf '{"type":"message","path":"%s/notes.md"}\n' "$CH/sibling" >>"$SESSIONS/-r chat "$SESSIONS/-nest-deep-wt" own "$CH/nest/deep:wt" chat "$SESSIONS/-tmp-temp-wt" own "$CT/temp:wt" chat "$SESSIONS/$ABS_BUCKET" own "$FIXTURE/abs:wt" +# A session started in a subdirectory sits in that subdirectory's bucket, for a home and an +# absolute worktree alike. A bucket that only shares a name prefix with a worktree is no chat in it. +chat "$SESSIONS/-subdir-src" sub "$CH/subdir/src" +chat "$SESSIONS/--$(printf '%s' "${FIXTURE#/}" | tr '/' '-')-sub-abs-src--" sub "$FIXTURE/sub:abs/src" +chat "$SESSIONS/-plain-old" old "$CH/plain-old" chat "$SESSIONS/-another-project" other "$CH/unrelated" TODAY=$(date '+%Y-%m-%d') @@ -217,7 +224,7 @@ expect plain LOCKED - expect plain INSPECT ok expect plain LAST_CHAT - expect plain BUCKET safe -for name in "$RECENT" sibling deep:wt temp:wt abs:wt; do +for name in "$RECENT" sibling deep:wt temp:wt abs:wt subdir sub:abs; do expect "$name" LAST_CHAT "$TODAY" expect "$name" BUCKET verify-recent-chat done @@ -238,25 +245,79 @@ for name in "$RECENT" deep:wt temp:wt abs:wt; do expect "$name" BUCKET verify-recent-chat done -# An explicit session directory holds transcripts directly, with no cwd bucket under it. +# An explicit session directory holds transcripts directly, with no cwd bucket under it. The bucket +# store still counts, since a session launched without the variable lands there. chat "$FIXTURE/direct sessions" direct "$CH/plain" audit explicit-dir PI_CODING_AGENT_SESSION_DIR="$FIXTURE/direct sessions" expect plain LAST_CHAT "$TODAY" expect plain BUCKET verify-recent-chat -expect "$RECENT" LAST_CHAT - +expect "$RECENT" LAST_CHAT "$TODAY" -# A profile owns its own store and outranks PI_CODING_AGENT_DIR. +# A session launched with --profile work sits in that profile's store, which an audit under any +# profile reads. The audit's own profile names the store that has to exist, and under a profile +# PI_CODING_AGENT_DIR does not name it. chat "$HOME/.omp/profiles/work/agent/sessions/-plain" profile "$CH/plain" -mkdir -p "$FIXTURE/decoy/sessions" +audit other-profile +expect plain LAST_CHAT "$TODAY" +expect plain BUCKET verify-recent-chat audit profile OMP_PROFILE=work PI_CODING_AGENT_DIR="$FIXTURE/decoy" expect plain LAST_CHAT "$TODAY" -expect "$RECENT" LAST_CHAT - +expect "$RECENT" LAST_CHAT "$TODAY" +rm -r "$HOME/.omp/profiles" -# An existing XDG_DATA_HOME/omp replaces the default data root, with no agent/ under it. +# An existing XDG_DATA_HOME/omp replaces the default data root, with no agent/ under it. The store +# it replaced still counts. chat "$FIXTURE/xdg/omp/sessions/-plain" xdg "$CH/plain" audit xdg XDG_DATA_HOME="$FIXTURE/xdg" expect plain LAST_CHAT "$TODAY" -expect "$RECENT" LAST_CHAT - +expect "$RECENT" LAST_CHAT "$TODAY" + +# omp lists each transcript it writes outside the bucket store, from --session-dir, an explicit +# PI_CODING_AGENT_SESSION_DIR or a --resume of a moved file, as one absolute path per file in the +# agent dir's custom-session-files, or XDG_STATE_HOME/omp's when that exists. A subagent transcript +# sits in the directory named after its parent. A listed transcript that is gone is no chat. One +# behind a directory the audit cannot search may still exist, so it is unknown, and so is an index +# that cannot be read. +INDEX="$HOME/.omp/agent/custom-session-files" +chat "$FIXTURE/custom dir" custom "$FIXTURE" +chat "$FIXTURE/custom dir/custom" Child "$CH/plain" +mkdir -p "$INDEX" +printf '%s' "$FIXTURE/custom dir/custom.jsonl" >"$INDEX/0a1b2c3" +printf '%s' "$FIXTURE/gone/x.jsonl" >"$INDEX/4d5e6f7" +audit custom-dir +expect plain LAST_CHAT "$TODAY" +expect plain BUCKET verify-recent-chat +# Under a symlinked HOME omp lists every bucket transcript as well, under the HOME it was given. +# The bucket scan already covers those, so one in another project's bucket stays out of the scan. +printf '%s' "$SESSIONS/-another-project/other.jsonl" >"$INDEX/8a9b0c1" +printf '%s' "$SESSIONS/-recent [one].worktree/own.jsonl" >"$INDEX/2d3e4f5" +audit listed-buckets +expect unrelated LAST_CHAT - +expect unrelated BUCKET safe +expect "$RECENT" LAST_CHAT "$TODAY" +if [ "$(id -u)" != 0 ]; then + chmod 000 "$FIXTURE/custom dir" + audit closed-transcript-dir + expect plain LAST_CHAT '?' + expect plain BUCKET verify-recent-chat + chmod 755 "$FIXTURE/custom dir" + chat "$FIXTURE/vault/inner" s "$CH/plain" + printf '%s' "$FIXTURE/vault/inner/s.jsonl" >"$INDEX/6e7f809" + chmod 000 "$FIXTURE/vault" + audit closed-ancestor + expect plain LAST_CHAT '?' + chmod 755 "$FIXTURE/vault" + chmod 000 "$INDEX" + audit unreadable-index + expect plain LAST_CHAT '?' + chmod 755 "$INDEX" +else + printf 'worktree audit: closed directory and index cases SKIPPED, root searches every directory\n' +fi +mkdir -p "$FIXTURE/state/omp" +mv "$INDEX" "$FIXTURE/state/omp/" +audit state-index XDG_STATE_HOME="$FIXTURE/state" +expect plain LAST_CHAT "$TODAY" # omp fills each unset variable from four .env files, the launch directory's, the agent # directory's, the config root's and the home directory's, and reads an OMP_ key in one as its PI_ @@ -304,30 +365,51 @@ audit no-tmpdir -u TMPDIR [ ! -e "$REPO/.pstack-scratch" ] || fail "scratch written inside the repo: $REPO/.pstack-scratch" [ -z "$(git -C "$REPO" status --porcelain --ignored)" ] || fail "the audit left files in the repo" -# omp names the transcript it would write for a worktree without a model turn. A chat written at -# that path has to reach the audit for a home, a temp and an absolute worktree. +# omp names the transcript it would write for a cwd without a model turn. A chat written at that +# path has to reach the audit for a home, a temp and an absolute worktree, for a session started in +# a subdirectory of one, and for one launched with --profile or --session-dir. +probe() { + local file + wt "$1" "$2" + mkdir -p "$3" + if ! file=$(cd "$3" && printf '%s\n' '{"id":"s","type":"get_state"}' | + env -i HOME="$HOME" TMPDIR="$TMPDIR" PATH="$PATH" omp --mode rpc --no-title --no-lsp --no-tools \ + --no-rules --no-extensions --no-skills --model probe/no-inference "${@:4}" 2>"$FIXTURE/live-omp.err" | + jq -r 'select(.type == "response" and .id == "s") | .data.sessionFile // empty') || [ -z "$file" ]; then + fail "omp reported no session file for $3: $(tr '\n' ' ' <"$FIXTURE/live-omp.err")" + return + fi + chat "$(dirname "$file")" "$(basename "$file" .jsonl)" "$3" +} if ! command -v omp >/dev/null 2>&1; then - printf 'worktree audit: live omp probe SKIPPED, omp is not on PATH\n' + if [ "${OMP_AUDIT_LIVE_REQUIRED:-}" = 1 ]; then + LABEL=live-omp + fail "omp is not on PATH and OMP_AUDIT_LIVE_REQUIRED=1" + else + printf 'worktree audit: live omp probe SKIPPED, omp is not on PATH; set OMP_AUDIT_LIVE_REQUIRED=1 to fail instead\n' + fi else LABEL=live-omp printf '%s\n' providers: ' probe:' ' baseUrl: http://127.0.0.1:9/v1' ' auth: none' \ ' api: openai-completions' ' models:' ' - id: no-inference' >"$HOME/.omp/agent/models.yml" - for spec in "probe-home:$CH/probe home" "probe-temp:$CT/probe:temp" "probe-abs:$FIXTURE/probe:abs"; do - path=${spec#*:} - wt "${spec%%:*}" "$path" - if ! file=$(cd "$path" && printf '%s\n' '{"id":"s","type":"get_state"}' | - env -i HOME="$HOME" TMPDIR="$TMPDIR" PATH="$PATH" omp --mode rpc --no-title --no-lsp \ - --no-tools --no-rules --no-extensions --no-skills --model probe/no-inference 2>"$FIXTURE/live-omp.err" | - jq -r 'select(.type == "response" and .id == "s") | .data.sessionFile // empty') || [ -z "$file" ]; then - fail "omp reported no session file for $path: $(tr '\n' ' ' <"$FIXTURE/live-omp.err")" - continue - fi - chat "$(dirname "$file")" "$(basename "$file" .jsonl)" "$path" - done + mkdir -p "$HOME/.omp/profiles/live/agent" + cp "$HOME/.omp/agent/models.yml" "$HOME/.omp/profiles/live/agent/" + probe probe-home "$CH/probe home" "$CH/probe home" + probe probe-temp "$CT/probe:temp" "$CT/probe:temp" + probe probe-abs "$FIXTURE/probe:abs" "$FIXTURE/probe:abs" + probe probe-sub "$CH/probe sub" "$CH/probe sub/src" + probe probe-sub-abs "$FIXTURE/probe:sub" "$FIXTURE/probe:sub/src" + probe probe-profile "$CH/probe profile" "$CH/probe profile" --profile live + # Under a symlinked HOME omp also lists each bucket transcript in custom-session-files, which would + # hide whether the audit finds the bucket itself. + rm -rf "$HOME/.omp/agent/custom-session-files" "$HOME/.omp/profiles/live/agent/custom-session-files" audit live-omp - for name in "probe home" probe:temp probe:abs; do + for name in "probe home" probe:temp probe:abs "probe sub" probe:sub "probe profile"; do expect "$name" LAST_CHAT "$TODAY" done + probe probe-session-dir "$CH/probe sdir" "$CH/probe sdir" --session-dir "$FIXTURE/live sessions" + audit live-omp-session-dir + expect "probe sdir" LAST_CHAT "$TODAY" fi if [ "$FAILS" -gt 0 ]; then diff --git a/plugins/pstack/skills/omp-mechanics/references/session-store-on-omp.md b/plugins/pstack/skills/omp-mechanics/references/session-store-on-omp.md index 0b7db11..db8559a 100644 --- a/plugins/pstack/skills/omp-mechanics/references/session-store-on-omp.md +++ b/plugins/pstack/skills/omp-mechanics/references/session-store-on-omp.md @@ -56,10 +56,36 @@ home and temp roots are canonicalized too, and `/`, `\`, and `:` all become `-` - Anything else drops its leading `/` and becomes `----`, so `/srv/a:b` is `--srv-a-b--`. +The cwd is the launch directory, not its git root. A session started in `/home/you/proj/src` sits in +`-proj-src`, and one in `/srv/a/src` in `--srv-a-src--`, beside the project's own bucket. + Two distinct paths that encode identically share one bucket unless the live runtime separates them. Probe the current store after an upgrade. Older stores may contain a short-lived hashed form, `--`, which is migrated back to path-encoded names on access. +## Transcripts outside the buckets + +`--session-dir`, `PI_CODING_AGENT_SESSION_DIR`, and a `--resume` of a transcript outside the store +write wherever they point. omp lists each such transcript in `custom-session-files/` under the +active agent directory, or under `$XDG_STATE_HOME/omp` when that exists, so each profile keeps its +own list. One file per session holds one absolute transcript path with no newline. omp writes it +when the session starts, before any message. Under a `$HOME` reached through a symlink it lists +bucket transcripts there too. Measured in a throwaway `HOME`, so list a real one after an upgrade. + +To find every transcript of a project, read its buckets in every store, `~/.omp/agent` and each +`~/.omp/profiles//agent`, then every path those lists name. A listed file that is gone was +deleted or moved. + +A store moved at launch by the process environment has no list entry a reader can find. A session +started with `PI_CODING_AGENT_DIR`, `PI_CONFIG_DIR`, `XDG_DATA_HOME`, or `XDG_STATE_HOME` set writes +its transcript and its list entry under a root that a reader without the same variable never +resolves. Under `PI_CODING_AGENT_DIR` only omp's per-process debug log in `~/.omp/logs` still names +the file, and a log is not an index. A `sessionDir` key in `config.yml` or a `--config` overlay +moves nothing. `--no-session` writes no transcript at all. The worktree audit cannot see these +sessions either. Rerun it with the variables those sessions launched with, or read +`get_state.sessionFile` from the live session, before you trust a `safe` row for a worktree you +used that way. + ## The files One flat transcript per session, `_.jsonl`. The timestamp is ISO-8601 with diff --git a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh index dc3dbd7..ed7c540 100755 --- a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -80,9 +80,10 @@ fi # PI_PROFILE) under ~/${PI_CONFIG_DIR:-.omp}, PI_CODING_AGENT_DIR when no # profile is set, or an existing XDG_DATA_HOME/omp when the agent dir is the # default, and it holds one bucket per canonical cwd. Only the buckets of this -# repo's worktrees are scanned; walking every bucket would scale with total -# session history. A store that cannot be read makes LAST_CHAT "?" and holds -# the row at verify-recent-chat instead of reading as no chat at all. +# repo's worktrees and their subdirectories are scanned; walking every bucket +# would scale with total session history. A store that cannot be read makes +# LAST_CHAT "?" and holds the row at verify-recent-chat instead of reading as +# no chat at all. # omp also fills each unset variable from the .env in the cwd it launched in, # then the agent dir's, the config root's and home's, reading an OMP_ key there # as its PI_ alias, while the profile comes from the environment alone. The @@ -109,31 +110,83 @@ bucket_name() { else printf '%s' "--$(dash "${1#/}")--"; fi } session_dirs=() -moved="" +dotenvs=() +config_root="$HOME/${PI_CONFIG_DIR:-.omp}" if [ -n "${PI_CODING_AGENT_SESSION_DIR:-}" ]; then sessions=$PI_CODING_AGENT_SESSION_DIR session_dirs=("$sessions/") else profile=$(printf '%s' "${OMP_PROFILE-${PI_PROFILE-}}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') [ "$profile" = default ] && profile="" - config="$HOME/${PI_CONFIG_DIR:-.omp}${profile:+/profiles/$profile}" + config="$config_root${profile:+/profiles/$profile}" agent="$config/agent" [ -z "$profile" ] && [ -n "${PI_CODING_AGENT_DIR:-}" ] && agent=$PI_CODING_AGENT_DIR data=$agent xdg="${XDG_DATA_HOME:-}/omp${profile:+/profiles/$profile}" [ "$agent" = "$config/agent" ] && [ -n "${XDG_DATA_HOME:-}" ] && [ -e "$xdg" ] && data=$xdg sessions="$data/sessions" - home_root=$(canon "$HOME") - temp_root=$(canon "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}") dotenvs=("$agent/.env" "$config/.env" "$HOME/.env") - while IFS= read -r path; do - dir="$sessions/$(bucket_name "$(canon "$path")")" - [ -d "$dir" ] && session_dirs+=("$dir/") - dotenvs+=("$path/.env") - done < <(printf '%s\n' "$worktree_records" | awk '/^worktree /{sub(/^worktree /, ""); print}') +fi +# A session launched with --profile, --session-dir or another environment is +# not in that store. Every store under the config root and the XDG roots counts +# too, and each one's custom-session-files lists the absolute path of every +# transcript omp wrote outside the bucket store. Precedence does not matter +# for these, since a transcript only counts when it names the worktree. +home_root=$(canon "$HOME") +temp_root=$(canon "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}") +roots=("$config_root/agent" "$config_root"/profiles/*/agent) +for x in "${XDG_DATA_HOME:-}" "${XDG_STATE_HOME:-}"; do + [ -n "$x" ] && roots+=("$x/omp" "$x"/omp/profiles/*) +done +[ -n "${PI_CODING_AGENT_DIR:-}" ] && roots+=("$PI_CODING_AGENT_DIR") +indexes=() +stores=$'\n' +for root in "${roots[@]}"; do + [ -d "$root/custom-session-files" ] && indexes+=("$root/custom-session-files") + [ -d "$root/sessions" ] && stores+="$(canon "$root/sessions")"$'\n' +done +while IFS= read -r path; do + cpath=$(canon "$path") + own=$(bucket_name "$cpath") + # A session started in a subdirectory has that subdirectory's bucket, the + # worktree's name plus "-" ahead of any closing "--". A transcript + # still has to name the worktree path, so a bucket that only shares the + # prefix costs time, never a match. The main worktree is skipped, since a + # repo at $HOME would make this the whole store. + sub=$(bucket_name "$cpath/*") + for root in "${roots[@]}"; do + [ -d "$root/sessions/$own" ] && session_dirs+=("$root/sessions/$own/") + [ "$path" = "$main_wt" ] && continue + for dir in "$root/sessions/${sub%\**}"*"${sub##*\*}"; do + [ -d "$dir" ] && session_dirs+=("$dir/") + done + done + dotenvs+=("$path/.env") +done < <(printf '%s\n' "$worktree_records" | awk '/^worktree /{sub(/^worktree /, ""); print}') +# Under a symlinked home omp lists every bucket transcript too, and the bucket +# scan already covers those. They share one parent, so it is canonicalized +# once rather than once per transcript. Any other listed transcript that is +# gone holds no chat, but one behind a directory the audit cannot search may +# still exist. Its subagents sit in the directory named after it. +listed=$([ "${#indexes[@]}" -eq 0 ] || grep -rhs '' -- "${indexes[@]}") +index_status=$? +seen="" store="" closed="" +while IFS= read -r f; do + [ "${f%/*/*}" = "$seen" ] || { seen=${f%/*/*}; store=$(canon "$seen"); } + case "$stores" in *$'\n'"$store"$'\n'*) continue ;; esac + if [ ! -f "$f" ]; then + dir=${f%/*} + while [ ! -e "$dir" ] && [ "$dir" != "${dir%/*}" ]; do dir=${dir%/*}; done + [ -d "$dir" ] && [ ! -x "$dir" ] && closed=$dir + continue + fi + session_dirs+=("$f") + [ -d "${f%.jsonl}" ] && session_dirs+=("${f%.jsonl}/") +done <<<"$listed" +moved="" +[ -n "${PI_CODING_AGENT_SESSION_DIR:-}" ] || moved=$(grep -lsE '^[[:space:]]*(export[[:space:]]+)?((PI|OMP)_(CODING_AGENT_SESSION_DIR|CODING_AGENT_DIR|CONFIG_DIR)|XDG_DATA_HOME)[[:space:]]*[=:]' \ -- "${dotenvs[@]}") -fi chat_lookup=ok if [ -n "$moved" ]; then chat_lookup=failed @@ -141,6 +194,12 @@ if [ -n "$moved" ]; then elif [ ! -d "$sessions" ]; then chat_lookup=failed echo "warn: no omp session store at $sessions; LAST_CHAT is ? and rows hold at verify-recent-chat" >&2 +elif [ "$index_status" -gt 1 ]; then + chat_lookup=failed + echo "warn: cannot read an omp custom-session-files index; LAST_CHAT is ? and rows hold at verify-recent-chat" >&2 +elif [ -n "$closed" ]; then + chat_lookup=failed + echo "warn: cannot search $closed for a listed omp transcript; LAST_CHAT is ? and rows hold at verify-recent-chat" >&2 fi now=$(date +%s)