From 0deb916fd443b90f011bf35db14d5e42ae0e1f8d Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Thu, 27 Aug 2026 02:41:56 +0530 Subject: [PATCH 01/14] fix: operate on the journal the agent actually needs, and stop the Stop-hook loops Journal selection ----------------- The pointer and the nudge both decided "newest journal" by mtime alone. Whenever a filesystem hands several journals the same timestamp -- a fresh clone, a branch checkout, an unpacked archive -- bash 3.2 compares -nt at whole-second resolution, the comparison ties, and the loop keeps whichever name the glob yielded first: the OLDEST journal. A teammate cloning the repo got a January journal presented as the frontier while August's sat unread. mtime still leads, so a journal appended to across days stays live; the filename, which the spec defines as the session-start time, now settles ties. The same corrected loop lands in the spine-promotion nudge. The pointer also replayed a DONE journal's next step verbatim. Running it in this repository injected "No repository work remains" as a fresh agent's entire first minute. DONE is a finished session, so the pointer now prefers the newest journal still ACTIVE or BLOCKED and, when every journal is closed, says so instead of pretending. Next-step headings match case-insensitively: a journal written from memory routinely title-cases the heading, and an exact match silently dropped the one section the hook exists to deliver. Secrets guard ------------- The guard blocked the exact remediation its own message prescribed. `password: ` and `token: stored-in-ci-secrets` both exited 2, because the pattern flagged any 6+ non-space token after a credential key. The guard deliberately runs ahead of the stop_hook_active bail, so the runtime could not break the resulting block -> redact -> block loop. Values are now inspected rather than merely detected: a report needs credential length, a credential alphabet, and no placeholder shape. The error message finally names the escape it always had. The digest-context exemption matched unanchored substrings, so "Avoid", "Android" and "paranoid" all satisfied `oid` and silently disabled hex detection for neighbouring lines. Context now compares whole words. Stop-hook robustness -------------------- stdin is drained in full instead of one line, so pretty-printed hook JSON no longer defeats stop_hook_active AND suppresses the marker fallback at once; the per-read timeout stays, because a plain `cat` would hang a Stop hook against a runner that never closes stdin. The one-shot marker gained a TTL: it used to outlive the session that armed it and silently swallow the next session's first nudge. Freshness measured a property the agent could set directly -- a bare `touch` satisfied the gate with zero characters changed. Where git can tell us, the gate now requires the journal to have actually moved when other files did. SESSION_JOURNAL_SKIP documents the escape for trivial sessions that need no journal, and JOURNAL_REQUIRE_CONTENT_CHANGE=0 restores mtime-only behaviour. The secrets guard still runs under both. Tests: 41 -> 70 checks. --- .gitignore | 2 + .../journal-spine-promotion-nudge.sh | 27 ++- hooks/session-journal-nudge.sh | 194 +++++++++++++++--- hooks/session-journal-pointer.sh | 127 ++++++++++-- tests/nudge.bats | 117 +++++++++++ tests/pointer.bats | 86 ++++++++ tests/secrets-guard.bats | 41 ++++ tests/test_helper.bash | 20 ++ 8 files changed, 561 insertions(+), 53 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c95b5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +sessions/*.local.md +.DS_Store diff --git a/extras/spine-promotion/journal-spine-promotion-nudge.sh b/extras/spine-promotion/journal-spine-promotion-nudge.sh index 698c601..4abf916 100755 --- a/extras/spine-promotion/journal-spine-promotion-nudge.sh +++ b/extras/spine-promotion/journal-spine-promotion-nudge.sh @@ -1,5 +1,7 @@ #!/bin/bash # Optional Stop hook for recurring DETAILED journals. +# +# session-journal — https://github.com/dctmfoo/session-journal (MIT) set -e @@ -9,18 +11,37 @@ case "$SESSIONS_DIR" in /*) ;; *) SESSIONS_DIR="$PROJECT_DIR/$SESSIONS_DIR" ;; e THRESHOLD="${SPINE_PROMOTION_THRESHOLD:-6}" TARGET_HINT="${SPINE_PROMOTION_TARGET_HINT:-docs/plans/}" +# Newest journal: mtime leads, filename settles ties. See the long note in +# session-journal-pointer.sh for why mtime alone picks the oldest after a clone. LATEST="" +LATEST_BASE="" for candidate in "$SESSIONS_DIR"/*.md; do [ -e "$candidate" ] || continue - [ "$(basename "$candidate")" = "README.md" ] && continue - if [ -z "$LATEST" ] || [ "$candidate" -nt "$LATEST" ]; then LATEST="$candidate"; fi + base=$(basename "$candidate") + case "$base" in README.md|TEMPLATE.md) continue ;; esac + if [ -z "$LATEST" ]; then + LATEST=$candidate + LATEST_BASE=$base + elif [ "$candidate" -nt "$LATEST" ]; then + LATEST=$candidate + LATEST_BASE=$base + elif [ ! "$LATEST" -nt "$candidate" ] && [ "$base" \> "$LATEST_BASE" ]; then + LATEST=$candidate + LATEST_BASE=$base + fi done [ -n "$LATEST" ] || { printf '{}\n'; exit 0; } PLAN=$(awk '/^## Live plan pointer[[:space:]]*$/{inside=1;next} inside&&/^## /{exit} inside&&NF{print;exit}' "$LATEST" 2>/dev/null || true) [ -z "$PLAN" ] || [ "$(printf '%s' "$PLAN" | tr '[:upper:]' '[:lower:]')" = "none" ] || { printf '{}\n'; exit 0; } -COUNT=$(awk '/^## Milestones[[:space:]]*$/{inside=1;next} inside&&/^## /{exit} inside&&/^[[:space:]]*[-*] /{count++} END{print count+0}' "$LATEST") +# An unreadable journal must not hand raw awk diagnostics to the agent as a Stop +# block. Failing open here is correct: this is an optional advisory nudge, and +# the core freshness and secrets hooks own the enforcement path. +COUNT=$(awk '/^## Milestones[[:space:]]*$/{inside=1;next} inside&&/^## /{exit} inside&&/^[[:space:]]*[-*] /{count++} END{print count+0}' "$LATEST" 2>/dev/null || printf '0') +case "$COUNT" in + ''|*[!0-9]*) COUNT=0 ;; +esac if [ "$COUNT" -ge "$THRESHOLD" ]; then echo "SESSION-JOURNAL-SPINE-PROMOTION: this DETAILED journal has $COUNT milestones. Promote recurring work into a durable plan or specification under $TARGET_HINT, point the journal to it, and continue in THIN mode." >&2 exit 2 diff --git a/hooks/session-journal-nudge.sh b/hooks/session-journal-nudge.sh index cdb258f..859c77d 100755 --- a/hooks/session-journal-nudge.sh +++ b/hooks/session-journal-nudge.sh @@ -4,6 +4,8 @@ # Claude Code and Codex pass JSON on stdin. stop_hook_active prevents a second # freshness block, while the secrets guard always runs. Some runners provide no # stdin; for those only, a short-lived marker supplies one-shot loop protection. +# +# session-journal — https://github.com/dctmfoo/session-journal (MIT) set -e @@ -13,10 +15,38 @@ case "$SESSIONS_DIR" in /*) ;; *) SESSIONS_DIR="$PROJECT_DIR/$SESSIONS_DIR" ;; esac + FRESH_SECS="${JOURNAL_FRESH_SECS:-300}" +case "$FRESH_SECS" in + ''|*[!0-9]*) + echo "SESSION-JOURNAL: JOURNAL_FRESH_SECS='$FRESH_SECS' is not a whole number of seconds; using 300." >&2 + FRESH_SECS=300 + ;; +esac +# One-shot markers exist to break a stop cascade within a single session. Without +# a TTL the marker outlives the session that armed it and silently swallows the +# next session's first nudge, so anything older than this is treated as stale. +MARKER_TTL="${JOURNAL_STOP_MARKER_TTL_SECS:-60}" +case "$MARKER_TTL" in + ''|*[!0-9]*) MARKER_TTL=60 ;; +esac + +# Read the WHOLE stdin payload, not just its first line. A single `read` stops +# at the first newline, so a runner that pretty-prints its hook JSON would hand +# us "{", which defeats the stop_hook_active check AND suppresses the marker +# fallback below (because the payload is non-empty), leaving no loop protection +# at all. The per-read timeout is kept: some runners attach a stdin they never +# close, and a plain `cat` would hang the Stop hook forever. INPUT="" -IFS= read -r -t 2 INPUT 2>/dev/null || true +STDIN_LINE="" +while IFS= read -r -t 2 STDIN_LINE; do + INPUT="$INPUT$STDIN_LINE +" + STDIN_LINE="" +done +[ -z "$STDIN_LINE" ] || INPUT="$INPUT$STDIN_LINE" + STOP_ACTIVE=false if [ -n "$INPUT" ]; then if command -v python3 >/dev/null 2>&1; then @@ -25,7 +55,7 @@ try: print("true" if json.load(sys.stdin).get("stop_hook_active") is True else " except Exception: print("false")' 2>/dev/null || printf false) elif command -v jq >/dev/null 2>&1; then STOP_ACTIVE=$(printf '%s' "$INPUT" | jq -r 'if .stop_hook_active == true then "true" else "false" end' 2>/dev/null || printf false) - elif printf '%s' "$INPUT" | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then + elif printf '%s' "$INPUT" | tr '\n' ' ' | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then STOP_ACTIVE=true fi fi @@ -35,18 +65,37 @@ if [ -z "$INPUT" ]; then PROJECT_KEY=$(printf '%s' "$PROJECT_DIR" | cksum | awk '{print $1}') MARKER="${TMPDIR:-/tmp}/session-journal-nudge-$PROJECT_KEY.d" if [ -d "$MARKER" ]; then + MARKER_MTIME="" + MARKER_MTIME=$(stat -f %m "$MARKER" 2>/dev/null || stat -c %Y "$MARKER" 2>/dev/null || printf '') rmdir "$MARKER" 2>/dev/null || true - STOP_ACTIVE=true + if [ -n "$MARKER_MTIME" ] && [ "$(( $(date +%s) - MARKER_MTIME ))" -le "$MARKER_TTL" ]; then + STOP_ACTIVE=true + fi fi fi -mkdir -p "$SESSIONS_DIR" +# A Stop hook is not an installer: it must not create the sessions directory as +# a side effect. The glob below handles a nonexistent directory correctly. + +# Newest journal: mtime leads, filename settles ties. mtime alone is wrong +# whenever several journals share a timestamp -- a fresh `git clone`, a branch +# checkout, an unpacked archive -- because bash 3.2 compares `-nt` at +# whole-second resolution and the tie keeps the alphabetically OLDEST file. LATEST="" +LATEST_BASE="" for candidate in "$SESSIONS_DIR"/*.md; do [ -e "$candidate" ] || continue - [ "$(basename "$candidate")" = "README.md" ] && continue - if [ -z "$LATEST" ] || [ "$candidate" -nt "$LATEST" ]; then - LATEST="$candidate" + base=$(basename "$candidate") + case "$base" in README.md|TEMPLATE.md) continue ;; esac + if [ -z "$LATEST" ]; then + LATEST=$candidate + LATEST_BASE=$base + elif [ "$candidate" -nt "$LATEST" ]; then + LATEST=$candidate + LATEST_BASE=$base + elif [ ! "$LATEST" -nt "$candidate" ] && [ "$base" \> "$LATEST_BASE" ]; then + LATEST=$candidate + LATEST_BASE=$base fi done @@ -54,30 +103,70 @@ done # echoed back: diagnostics identify line numbers so a hook cannot leak a secret. if [ -n "$LATEST" ]; then CANDIDATES=$(grep -nEv '' "$LATEST" 2>/dev/null || true) - # Credential shapes that carry their own prefix or assignment are always flagged. + + # Credential shapes that carry their own prefix are unambiguous: a JWT, a + # bearer value, an AWS key id, a GitHub or Slack token, a private-key header. NAMED=$(printf '%s\n' "$CANDIDATES" \ - | grep -Ei 'e[y]J[A-Za-z0-9_-]{20,}|Bearer[[:space:]]+[A-Za-z0-9._-]{16,}|(client[_-]?secret|api[_-]?key|password|passphrase|token)[[:space:]]*[:=][[:space:]]*[^[:space:]]{6,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{16,}|-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----' || true) - # A bare long hex string is only suspicious away from digest context. SHA-256 artifact - # receipts, git revisions, and fingerprints are this program's evidence, not secrets, and - # must survive verbatim to stay verifiable. + | grep -Ei 'e[y]J[A-Za-z0-9_-]{20,}|Bearer[[:space:]]+[A-Za-z0-9._-]{16,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{16,}|-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----' || true) + + # `key: value` assignments need the VALUE inspected, not just the key name. + # The old pattern flagged any 6+ non-space token after a credential key, which + # blocked `password: ` and `token: stored-in-ci-secrets` -- the exact + # remediations this hook's own error message prescribes, producing a block -> + # redact -> block loop the runtime cannot break. A value is now reported only + # when it is long enough to be a credential, drawn from a credential alphabet, + # and not a placeholder -- either a leading symbol (, $ENV_VAR) or a + # leading word that describes where the secret lives (stored-in-ci-secrets). + ASSIGNED=$(awk ' + function is_placeholder(v, head) { + if (v ~ /^[<${*("'"'"'\[]/) return 1 + head = v + sub(/[-_.:].*$/, "", head) + return (head ~ /^(redacted|redact|redacts|placeholder|example|sample|dummy|fake|test|none|null|nil|unset|empty|blank|omitted|elided|scrubbed|masked|hidden|removed|rotated|expired|revoked|sanitized|sanitised|todo|tbd|changeme|see|stored|store|kept|held|managed|provided|supplied|injected|configured|set|use|using|from|in|via|your|my|the|not|no|never|only|env|vault|keychain|n)$/) + } + { + if ($0 ~ //) next + line = $0 + if (match(tolower(line), /(client[_-]?secret|api[_-]?key|secret[_-]?key|access[_-]?key|password|passphrase|token)[[:space:]]*[:=][[:space:]]*/) == 0) next + value = substr(line, RSTART + RLENGTH) + sub(/[[:space:]].*$/, "", value) + sub(/[",;.)]+$/, "", value) + if (length(value) < 12) next + if (value !~ /^[A-Za-z0-9._~+\/=-]+$/) next + if (is_placeholder(tolower(value))) next + print NR ":" line + } + ' "$LATEST" 2>/dev/null || true) + + # A bare long hex string is only suspicious away from digest context. SHA-256 + # artifact receipts, git revisions, and fingerprints are this program's + # evidence, not secrets, and must survive verbatim to stay verifiable. + # + # Context is read over a three-line window, NOT the hash's own line. Prose + # wraps, and a 64-character digest routinely lands on a line of its own with + # "hashes to" or "SHA-256" ending the line above; a single-line check sees a + # bare hex string and cries wolf. # - # Context is read over a three-line window, NOT the hash's own line. Prose wraps, and a - # 64-character digest routinely lands on a line of its own with "hashes to" or "SHA-256" - # ending the line above; a single-line check sees a bare hex string and cries wolf. + # Every non-alphanumeric run collapses to a single space before matching, so + # the keywords compare as whole words. Substring matching let "Avoid", + # "Android" and "paranoid" satisfy `oid`, and "committed" satisfy `commit`, + # silently disabling hex detection for their neighbouring lines. BARE_HEX=$(awk ' { lines[NR] = $0 } END { for (i = 1; i <= NR; i++) { if (lines[i] ~ //) continue - ctx = tolower((i > 1 ? lines[i-1] : "") " " lines[i] " " (i < NR ? lines[i+1] : "")) - if (ctx ~ /sha-?(1|256|384|512)|hash|digest|checksum|fingerprint|commit|revision|blob|oid|etag/) continue + ctx = " " tolower((i > 1 ? lines[i-1] : "") " " lines[i] " " (i < NR ? lines[i+1] : "")) " " + gsub(/[^a-z0-9]+/, " ", ctx) + if (ctx ~ / (sha|sha1|sha256|sha384|sha512|hash|hashes|hashed|digest|digests|checksum|checksums|fingerprint|fingerprints|commit|commits|revision|revisions|blob|blobs|oid|oids|etag|etags) /) continue print i ":" lines[i] } }' "$LATEST" 2>/dev/null | grep -Ei '[0-9a-f]{40,}' || true) - LEAK_LINES=$(printf '%s\n%s\n' "$NAMED" "$BARE_HEX" \ + + LEAK_LINES=$(printf '%s\n%s\n%s\n' "$NAMED" "$ASSIGNED" "$BARE_HEX" \ | grep -E '^[0-9]+:' | cut -d: -f1 | sort -n -u | head -n 3 | paste -sd, - || true) if [ -n "$LEAK_LINES" ]; then - echo "SESSION-JOURNAL-SECRETS-GUARD: secret-looking content found in $LATEST at line(s) $LEAK_LINES. Redact values to key names or safe length-only descriptions before finishing." >&2 + echo "SESSION-JOURNAL-SECRETS-GUARD: secret-looking content found in $LATEST at line(s) $LEAK_LINES (first three shown). Replace the value with a key name, an env-var reference, or a placeholder such as . If a human has confirmed the line carries no credential value, append to that line to exempt it." >&2 exit 2 fi fi @@ -87,6 +176,15 @@ if [ "$STOP_ACTIVE" = "true" ]; then exit 0 fi +# A trivial session -- a typo fix, a dependency bump -- does not need a journal. +# Without a documented escape the only ways out are to burn stop_hook_active or +# to write a journal about nothing, and both teach the agent to route around the +# discipline. The secrets guard above deliberately still runs. +case "${SESSION_JOURNAL_SKIP:-}" in + ''|0|false|no) ;; + *) printf '{}\n'; exit 0 ;; +esac + block_once() { [ -z "$MARKER" ] || mkdir "$MARKER" 2>/dev/null || true echo "$1" >&2 @@ -94,31 +192,59 @@ block_once() { } if [ -z "$LATEST" ]; then - block_once "SESSION-JOURNAL-REMINDER: No session journal exists. Create YYYY-MM-DD-HHMM-.md from the local template before finishing resumable work." + block_once "SESSION-JOURNAL-REMINDER: No session journal exists. Create YYYY-MM-DD-HHMM-.md from the local template before finishing resumable work. If this session needs no journal, set SESSION_JOURNAL_SKIP=1." fi if ! MTIME=$(stat -f %m "$LATEST" 2>/dev/null); then - MTIME=$(stat -c %Y "$LATEST") + MTIME=$(stat -c %Y "$LATEST" 2>/dev/null || printf '0') fi NOW=$(date +%s) AGE=$((NOW - MTIME)) -if [ "$AGE" -lt "$FRESH_SECS" ]; then - printf '{}\n' - exit 0 -fi PLAN_LINE=$(awk ' - /^## Live plan pointer[[:space:]]*$/ { inside=1; next } + tolower($0) ~ /^##[[:space:]]+live plan pointer[[:space:]]*$/ { inside=1; next } inside && /^## / { exit } inside && NF { print; exit } ' "$LATEST" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' || true) PLAN_LOWER=$(printf '%s' "$PLAN_LINE" | tr '[:upper:]' '[:lower:]') -case "$PLAN_LOWER" in - ""|none|\*) - block_once "SESSION-JOURNAL-REMINDER (DETAILED mode): the newest journal is ${AGE}s old. Update milestones, files, current state, next step, and Last updated; promote recurring work into a durable plan and switch to THIN." - ;; - *) - block_once "SESSION-JOURNAL-REMINDER (THIN mode — pointer: $PLAN_LINE): the newest journal is ${AGE}s old. Add one-line evidence and refresh current state, next step, and Last updated without duplicating the governing document." - ;; -esac +mode_reminder() { + case "$PLAN_LOWER" in + ""|none|\*) + block_once "SESSION-JOURNAL-REMINDER (DETAILED mode): $1 Update milestones, files, current state, next step, and Last updated; promote recurring work into a durable plan and switch to THIN." + ;; + *) + block_once "SESSION-JOURNAL-REMINDER (THIN mode — pointer: $PLAN_LINE): $1 Add one-line evidence and refresh current state, next step, and Last updated without duplicating the governing document." + ;; + esac +} + +# Freshness by mtime alone measures a property the agent can set directly: a +# bare `touch` satisfies the gate with zero characters changed. When git can +# tell us whether the journal actually moved, require content, not a timestamp. +journal_records_work() { + local journal=$1 dirty journal_dirty + case "${JOURNAL_REQUIRE_CONTENT_CHANGE:-1}" in + 0|false|no) return 0 ;; + esac + command -v git >/dev/null 2>&1 || return 0 + git -C "$PROJECT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + # Local-only journals are invisible to `git status`; fall back to mtime. + if git -C "$PROJECT_DIR" check-ignore -q "$journal" 2>/dev/null; then return 0; fi + dirty=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null | head -n 200 || true) + # Nothing changed anywhere: there is no work for the journal to record. + [ -n "$dirty" ] || return 0 + journal_dirty=$(git -C "$PROJECT_DIR" status --porcelain -- "$journal" 2>/dev/null || true) + [ -z "$journal_dirty" ] || return 0 + return 1 +} + +if [ "$AGE" -lt "$FRESH_SECS" ]; then + if journal_records_work "$LATEST"; then + printf '{}\n' + exit 0 + fi + mode_reminder "the newest journal's timestamp moved but its content did not, while other files in this repository did change." +fi + +mode_reminder "the newest journal is ${AGE}s old." diff --git a/hooks/session-journal-pointer.sh b/hooks/session-journal-pointer.sh index 85aae8c..01ead75 100755 --- a/hooks/session-journal-pointer.sh +++ b/hooks/session-journal-pointer.sh @@ -1,6 +1,8 @@ #!/bin/bash -# SessionStart hook: inject a compact pointer to the newest session journal. +# SessionStart hook: inject a compact pointer to the live session journal. # Compatible with bash 3.2 and with Claude Code and Codex command hooks. +# +# session-journal — https://github.com/dctmfoo/session-journal (MIT) set -e @@ -11,36 +13,124 @@ case "$SESSIONS_DIR" in *) SESSIONS_DIR="$PROJECT_DIR/$SESSIONS_DIR" ;; esac -mkdir -p "$SESSIONS_DIR" +# A SessionStart hook is a read path. It must not create directories: a +# read-only checkout or a mistargeted PROJECT_DIR would turn a missing-context +# event into a nonzero exit and lose the injection entirely. The glob below +# handles a nonexistent directory correctly on its own. +journal_status() { + # First word of the mandatory status line, upper-cased. Empty when absent. + sed -n 's/^[[:space:]]*\*\*Status:\*\*[[:space:]]*//p' "$1" 2>/dev/null \ + | head -n 1 \ + | awk '{print toupper($1)}' \ + | tr -cd '[:upper:]' +} + +# "Newest" is decided by modification time, with the filename as the tie-break. +# mtime alone is wrong whenever a filesystem hands several journals the same +# timestamp -- a fresh `git clone`, a branch checkout, an unpacked archive, a +# restored backup -- because bash 3.2 compares `-nt` at whole-second resolution +# and the tie silently keeps whichever name the glob yielded first, i.e. the +# OLDEST journal. The filename is the session-start time by specification and +# sorts lexicographically, so it settles ties correctly. mtime still leads, so a +# journal appended to across days stays live even though its name is older. LATEST="" +LATEST_BASE="" for candidate in "$SESSIONS_DIR"/*.md; do [ -e "$candidate" ] || continue - [ "$(basename "$candidate")" = "README.md" ] && continue - if [ -z "$LATEST" ] || [ "$candidate" -nt "$LATEST" ]; then - LATEST="$candidate" + base=$(basename "$candidate") + case "$base" in README.md|TEMPLATE.md) continue ;; esac + if [ -z "$LATEST" ]; then + LATEST=$candidate + LATEST_BASE=$base + elif [ "$candidate" -nt "$LATEST" ]; then + LATEST=$candidate + LATEST_BASE=$base + elif [ ! "$LATEST" -nt "$candidate" ] && [ "$base" \> "$LATEST_BASE" ]; then + LATEST=$candidate + LATEST_BASE=$base fi done +# A DONE journal is a finished session, not the live frontier. Prefer the newest +# journal still open for work; fall back to reporting the closed one honestly +# rather than replaying its "no work remains" next step to a fresh agent. +LIVE="" +LIVE_BASE="" +if [ -n "$LATEST" ] && [ "$(journal_status "$LATEST")" = "DONE" ]; then + for candidate in "$SESSIONS_DIR"/*.md; do + [ -e "$candidate" ] || continue + base=$(basename "$candidate") + case "$base" in README.md|TEMPLATE.md) continue ;; esac + case "$(journal_status "$candidate")" in + ACTIVE|BLOCKED) ;; + *) continue ;; + esac + if [ -z "$LIVE" ]; then + LIVE=$candidate + LIVE_BASE=$base + elif [ "$candidate" -nt "$LIVE" ]; then + LIVE=$candidate + LIVE_BASE=$base + elif [ ! "$LIVE" -nt "$candidate" ] && [ "$base" \> "$LIVE_BASE" ]; then + LIVE=$candidate + LIVE_BASE=$base + fi + done +fi + +relative_path() { + local resolved="${1#"$PROJECT_DIR"/}" + [ "$resolved" = "$1" ] && resolved="$1" + printf '%s' "$resolved" +} + if [ -z "$LATEST" ]; then - REL_SESSIONS="${SESSIONS_DIR#"$PROJECT_DIR"/}" - [ "$REL_SESSIONS" = "$SESSIONS_DIR" ] && REL_SESSIONS="$SESSIONS_DIR" - MESSAGE="No session journal exists in $REL_SESSIONS/. Once this session's intent is clear, create YYYY-MM-DD-HHMM-.md using the local sessions README and template." + REL_SESSIONS=$(relative_path "$SESSIONS_DIR") + MESSAGE="No session journal exists in $REL_SESSIONS/. Once this session's intent is clear, create YYYY-MM-DD-HHMM-.md from $REL_SESSIONS/TEMPLATE.md and follow $REL_SESSIONS/README.md." else - RELATIVE="${LATEST#"$PROJECT_DIR"/}" - [ "$RELATIVE" = "$LATEST" ] && RELATIVE="$LATEST" - STATUS_LINE=$(grep -m 1 '^\*\*Status:\*\*' "$LATEST" 2>/dev/null || true) + CLOSED="" + if [ "$(journal_status "$LATEST")" = "DONE" ]; then + if [ -n "$LIVE" ]; then + CLOSED=$(relative_path "$LATEST") + LATEST=$LIVE + else + CLOSED="only" + fi + fi + + RELATIVE=$(relative_path "$LATEST") + STATUS_LINE=$(grep -m 1 '^[[:space:]]*\*\*Status:\*\*' "$LATEST" 2>/dev/null || true) [ -n "$STATUS_LINE" ] || STATUS_LINE="**Status:** Status not recorded" + + # Heading matching is case-insensitive: a journal written from memory rather + # than from the template routinely title-cases the heading, and an exact match + # silently drops the one section this hook exists to deliver. NEXT_STEP=$(awk ' - /^## Next step for a fresh agent[[:space:]]*$/ { inside=1; next } + tolower($0) ~ /^##[[:space:]]+next step for a fresh agent[[:space:]]*$/ { inside=1; next } inside && /^## / { exit } inside { print } ' "$LATEST" 2>/dev/null | sed '/^[[:space:]]*$/d' || true) - [ -n "$NEXT_STEP" ] || NEXT_STEP="Next step not recorded" - MESSAGE="Most recent session journal: $RELATIVE. Read it before acting. Continue the same work by appending to it; start unrelated work in a new journal. + if [ -z "$NEXT_STEP" ]; then + NEXT_STEP="Next step not recorded. This journal has no \"## Next step for a fresh agent\" section; read it in full, then add that section before ending the turn." + fi + + if [ "$CLOSED" = "only" ]; then + MESSAGE="Most recent session journal: $RELATIVE — its status is DONE, so it is a finished session and not the live frontier. Read it for background, treat this session as new work, and create a new journal once the intent is clear. +$STATUS_LINE +Last recorded next step (from the closed journal): +$NEXT_STEP" + elif [ -n "$CLOSED" ]; then + MESSAGE="Live session journal: $RELATIVE. Read it before acting. Continue the same work by appending to it; start unrelated work in a new journal. A more recent journal ($CLOSED) is already DONE and is background only. $STATUS_LINE Next step for a fresh agent: $NEXT_STEP" + else + MESSAGE="Most recent session journal: $RELATIVE. Read it before acting. Continue the same work by appending to it; start unrelated work in a new journal. +$STATUS_LINE +Next step for a fresh agent: +$NEXT_STEP" + fi fi json_string() { @@ -49,8 +139,13 @@ json_string() { elif command -v python >/dev/null 2>&1; then python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' else - # Minimal documented fallback: preserve valid JSON by flattening newlines. - sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ' | sed 's/^/"/; s/$/"/' + # Minimal documented fallback: preserve valid JSON by escaping the control + # characters a journal can realistically contain, then flattening newlines. + sed 's/\\/\\\\/g; s/"/\\"/g' \ + | tr '\t' ' ' \ + | tr -d '\r' \ + | tr '\n' ' ' \ + | sed 's/^/"/; s/$/"/' fi } diff --git a/tests/nudge.bats b/tests/nudge.bats index d48faae..57e9965 100644 --- a/tests/nudge.bats +++ b/tests/nudge.bats @@ -66,3 +66,120 @@ teardown() { teardown_workspace; } [ "$status" -eq 2 ] [[ "$output" == *"No session journal exists"* ]] } + +@test "pretty-printed hook JSON still honours stop_hook_active" { + journal="$(write_journal)" + make_stale "$journal" + run bash -c "printf '{\n \"stop_hook_active\": true\n}\n' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 0 ] +} + +@test "an expired one-shot marker does not swallow a later session's nudge" { + journal="$(write_journal)" + make_stale "$journal" + marker_dir="${TMPDIR:-/tmp}/session-journal-nudge-$(printf '%s' "$TEST_ROOT" | cksum | awk '{print $1}').d" + + run "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" + [ "$status" -eq 2 ] + [ -d "$marker_dir" ] + + touch -t 202001010000 "$marker_dir" + run "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" + [ "$status" -eq 2 ] +} + +@test "SESSION_JOURNAL_SKIP suppresses the freshness nudge" { + journal="$(write_journal)" + make_stale "$journal" + run env SESSION_JOURNAL_SKIP=1 "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" <<< '{"stop_hook_active":false}' + [ "$status" -eq 0 ] +} + +@test "SESSION_JOURNAL_SKIP does not suppress the secrets guard" { + journal="$(write_journal)" + printf '%s\n' 'token = definitely-secret-value' >> "$journal" + run env SESSION_JOURNAL_SKIP=1 "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" <<< '{"stop_hook_active":false}' + [ "$status" -eq 2 ] + [[ "$output" == *"SECRETS-GUARD"* ]] +} + +@test "a non-numeric freshness window falls back to the default" { + journal="$(write_journal)" + make_stale "$journal" + run env JOURNAL_FRESH_SECS=5m "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" <<< '{"stop_hook_active":false}' + [ "$status" -eq 2 ] + [[ "$output" == *"is not a whole number of seconds"* ]] +} + +@test "identical mtimes select the later filename for the freshness check" { + thin="$(write_journal 2026-01-02-0304-alpha.md docs/plans/work.md 'ACTIVE - alpha')" + detailed="$(write_journal 2026-08-20-1500-charlie.md none 'ACTIVE - charlie')" + tie_mtimes "$thin" "$detailed" + touch -t 202001010000 "$thin" "$detailed" + + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 2 ] + [[ "$output" == *"DETAILED mode"* ]] +} + +@test "the nudge never creates the sessions directory" { + rmdir "$SESSION_JOURNAL_DIR" + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 2 ] + [ ! -d "$SESSION_JOURNAL_DIR" ] +} + +@test "a touched but unchanged journal does not satisfy the gate when other work happened" { + setup_git_workspace + journal="$(write_journal)" + commit_workspace "baseline" + printf 'v2\n' > "$TEST_ROOT/src/app.txt" + touch "$journal" + + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 2 ] + [[ "$output" == *"timestamp moved but its content did not"* ]] +} + +@test "a genuinely edited journal satisfies the gate" { + setup_git_workspace + journal="$(write_journal)" + commit_workspace "baseline" + printf 'v2\n' > "$TEST_ROOT/src/app.txt" + printf -- '- recorded the change\n' >> "$journal" + + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 0 ] +} + +@test "a clean work tree needs no journal movement" { + setup_git_workspace + write_journal >/dev/null + commit_workspace "baseline" + + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 0 ] +} + +@test "JOURNAL_REQUIRE_CONTENT_CHANGE=0 restores mtime-only freshness" { + setup_git_workspace + journal="$(write_journal)" + commit_workspace "baseline" + printf 'v2\n' > "$TEST_ROOT/src/app.txt" + touch "$journal" + + run env JOURNAL_REQUIRE_CONTENT_CHANGE=0 "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" <<< '{"stop_hook_active":false}' + [ "$status" -eq 0 ] +} + +@test "a gitignored journal falls back to mtime-only freshness" { + setup_git_workspace + journal="$(write_journal)" + printf 'sessions/\n' > "$TEST_ROOT/.gitignore" + commit_workspace "baseline" + printf 'v2\n' > "$TEST_ROOT/src/app.txt" + touch "$journal" + + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 0 ] +} diff --git a/tests/pointer.bats b/tests/pointer.bats index c0f4786..2c555af 100644 --- a/tests/pointer.bats +++ b/tests/pointer.bats @@ -37,3 +37,89 @@ teardown() { teardown_workspace; } [[ "$output" == *"Status not recorded"* ]] [[ "$output" == *"Next step not recorded"* ]] } + +@test "identical mtimes fall back to the filename instead of keeping the oldest" { + older="$(write_journal 2026-01-02-0304-alpha.md none 'ACTIVE - alpha')" + newer="$(write_journal 2026-08-20-1500-charlie.md none 'ACTIVE - charlie')" + tie_mtimes "$older" "$newer" + + run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"2026-08-20-1500-charlie.md"* ]] + [[ "$output" != *"2026-01-02-0304-alpha.md"* ]] +} + +@test "mtime still outranks the filename so an appended journal stays live" { + newer_name="$(write_journal 2026-08-20-1500-charlie.md none 'ACTIVE - charlie')" + sleep 1 + older_name="$(write_journal 2026-01-02-0304-alpha.md none 'ACTIVE - alpha')" + touch "$older_name" + touch -t 202001010000 "$newer_name" + + run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"2026-01-02-0304-alpha.md"* ]] +} + +@test "a DONE newest journal yields to an older journal still open for work" { + live="$(write_journal 2026-01-02-0304-live.md none 'ACTIVE - still working')" + sleep 1 + done_journal="$(write_journal 2026-01-02-0305-done.md none 'DONE - shipped')" + touch "$done_journal" + + run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" + [ "$status" -eq 0 ] + printf '%s' "$output" | jq -e . >/dev/null + [[ "$output" == *"Live session journal"* ]] + [[ "$output" == *"2026-01-02-0304-live.md"* ]] + [[ "$output" == *"2026-01-02-0305-done.md"* ]] + [ -n "$live" ] +} + +@test "a BLOCKED journal counts as open work" { + write_journal 2026-01-02-0304-blocked.md none 'BLOCKED - waiting on review' >/dev/null + sleep 1 + done_journal="$(write_journal 2026-01-02-0305-done.md none 'DONE - shipped')" + touch "$done_journal" + + run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"2026-01-02-0304-blocked.md"* ]] +} + +@test "when every journal is DONE the pointer says so instead of replaying its next step" { + write_journal 2026-01-02-0304-done.md none 'DONE - shipped' >/dev/null + + run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" + [ "$status" -eq 0 ] + printf '%s' "$output" | jq -e . >/dev/null + [[ "$output" == *"finished session"* ]] + [[ "$output" == *"treat this session as new work"* ]] + [[ "$output" != *"Next step for a fresh agent:"* ]] +} + +@test "a title-cased next-step heading still delivers the section" { + journal="$SESSION_JOURNAL_DIR/2026-01-02-0304-drift.md" + printf '**Status:** ACTIVE - drifting\n\n## Live plan pointer\nnone\n\n## Next Step For A Fresh Agent\nRun the drifted command.\n' > "$journal" + + run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"Run the drifted command."* ]] + [[ "$output" != *"Next step not recorded"* ]] +} + +@test "TEMPLATE.md is not mistaken for a journal" { + printf '# Session: \n\n**Status:** ACTIVE | DONE | BLOCKED\n' > "$SESSION_JOURNAL_DIR/TEMPLATE.md" + + run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"No session journal exists"* ]] +} + +@test "the pointer never creates the sessions directory" { + rmdir "$SESSION_JOURNAL_DIR" + run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" + [ "$status" -eq 0 ] + printf '%s' "$output" | jq -e . >/dev/null + [ ! -d "$SESSION_JOURNAL_DIR" ] +} diff --git a/tests/secrets-guard.bats b/tests/secrets-guard.bats index 938dced..73bf2da 100644 --- a/tests/secrets-guard.bats +++ b/tests/secrets-guard.bats @@ -75,3 +75,44 @@ assert_secret_blocks() { [ "$status" -eq 2 ] [[ "$output" == *"SECRETS-GUARD"* ]] } + +assert_secret_passes() { + local vector="$1" + journal="$(write_journal)" + printf '%s\n' "$vector" >> "$journal" + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 0 ] +} + +@test "an angle-bracket redaction is accepted" { assert_secret_passes 'password: '; } +@test "an annotated redaction is accepted" { assert_secret_passes 'api_key: '; } +@test "a length-only description is accepted" { assert_secret_passes 'api_key: redacted (32 chars)'; } +@test "a location description is accepted" { assert_secret_passes 'token: stored-in-ci-secrets'; } +@test "an env-var reference is accepted" { assert_secret_passes 'api_key: $STRIPE_API_KEY'; } +@test "a short placeholder is accepted" { assert_secret_passes 'client_secret: placeholder'; } + +@test "the guard names its own escape hatch when it blocks" { + journal="$(write_journal)" + printf '%s\n' 'token = definitely-secret-value' >> "$journal" + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 2 ] + [[ "$output" == *"journal-secrets-ok"* ]] +} + +@test "prose containing a digest keyword as a substring does not disable hex detection" { + journal="$(write_journal)" + printf '%s\n' 'Avoid rerunning the importer.' \ + '0123456789abcdef0123456789abcdef01234567' \ + 'Android build is green.' >> "$journal" + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 2 ] + [[ "$output" == *"SECRETS-GUARD"* ]] +} + +@test "a whole-word digest keyword still exempts a receipt" { + journal="$(write_journal)" + printf '%s\n' 'Release tarball SHA-256:' \ + '0123456789abcdef0123456789abcdef01234567' >> "$journal" + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 0 ] +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash index 4103b86..e4932ce 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -22,3 +22,23 @@ make_stale() { local path="$1" touch -t 202001010000 "$path" } + +# A workspace that is a real git work tree, for the content-change gate. +setup_git_workspace() { + setup_workspace + git -C "$TEST_ROOT" init -q + git -C "$TEST_ROOT" config user.email journal@test.invalid + git -C "$TEST_ROOT" config user.name "Journal Test" + mkdir -p "$TEST_ROOT/src" + printf 'v1\n' > "$TEST_ROOT/src/app.txt" +} + +commit_workspace() { + git -C "$TEST_ROOT" add -A >/dev/null + git -C "$TEST_ROOT" commit -qm "${1:-checkpoint}" +} + +# Give every journal the same whole-second mtime, the way a checkout does. +tie_mtimes() { + touch -t 202601020304 "$@" +} From a9e4e2a3a55261d6b4d234eb80f12ebcceacabc7 Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Thu, 27 Aug 2026 02:49:59 +0530 Subject: [PATCH 02/14] fix: make installation safe to run twice and safe to fail, and add a doctor The installer refused after it had already acted. An unmatched sentinel in CLAUDE.md produced "refusing to modify it" and exit 65 with both hook scripts already copied, both hooks already merged live into settings.json, and a stray .tmp file left in the repository: enforcement wired up with no instruction block to explain it. Every refusal -- invalid JSON, unmatched sentinels, a non-root git target, a journal directory that symlinks out of the repository -- now happens in a preflight, before the first byte. Re-running install.sh is the only upgrade path a clone-based project has, and it silently deleted all three things ADOPT.md instructs the adopting agent to write: the JOURNAL_FRESH_SECS override on the generated command, the adapted sessions README, and any local edit inside the instruction block. All three are preserved now, each with a line saying so, and --refresh-docs regenerates them deliberately. The installer never shipped the journal template. Both the pointer hook and the generated README told a fresh agent to start from a local template, and neither the installer nor ADOPT.md ever placed one -- ADOPT.md sends the agent to clone upstream "to a temporary location". Every first journal was therefore written from an agent's memory of a specification it had not read, which is where heading drift comes from. TEMPLATE.md now ships. Installing into a subdirectory of a git repository used to succeed and then fail forever: the generated command resolves its root at runtime with git rev-parse --show-toplevel, so it pointed at a path that did not exist. New: doctor.sh. ADOPT.md Phase C is a verification matrix the installing agent runs once and nobody can run again, yet every failure mode here is silent -- a SessionStart hook that never fires looks exactly like a session with no journal. The doctor re-runs that matrix at any time, and adds the checks adoption never had: is the installation committed, will a teammate actually receive it, does the registered command still resolve, which journal would the pointer deliver right now. Also: --plan-pointer, so the deterministic install can produce THIN mode instead of always contradicting ADOPT.md's instruction to point at the strongest governing artifact; a VERSION file stamped into the installed hooks so an adopted repo can tell what it has. Tests: 70 -> 89 checks (grep -c "^@test" tests/*.bats). --- VERSION | 1 + doctor.sh | 246 ++++++++++++++++++++++++++++++++++ install.sh | 249 ++++++++++++++++++++++++++--------- templates/session-journal.md | 16 ++- templates/sessions-README.md | 11 +- tests/doctor.bats | 89 +++++++++++++ tests/install.bats | 98 ++++++++++++++ 7 files changed, 643 insertions(+), 67 deletions(-) create mode 100644 VERSION create mode 100755 doctor.sh create mode 100644 tests/doctor.bats diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..9084fa2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.1.0 diff --git a/doctor.sh b/doctor.sh new file mode 100755 index 0000000..9499292 --- /dev/null +++ b/doctor.sh @@ -0,0 +1,246 @@ +#!/bin/bash +# Re-runnable self-check for an adopted session-journal installation. +# +# ADOPT.md Phase C is a verification matrix the installing agent runs once, at +# adoption time, and then nobody can run again. Hooks that stop firing are +# silent by construction: a SessionStart hook that never runs looks exactly like +# a session with no journal, and a Stop hook that never runs looks exactly like +# a journal that is always fresh. This script turns that one-time ritual into +# something a teammate, a CI job, or a suspicious agent can run at any time. +# +# Exit 0 when every check passes, 1 when any check fails. + +set -u + +TARGET="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +[ -d "$TARGET" ] || { echo "Target directory does not exist: $TARGET" >&2; exit 66; } +TARGET=$(cd "$TARGET" && pwd) + +FAILURES=0 +CHECKS=0 + +pass() { CHECKS=$((CHECKS + 1)); printf 'ok %s\n' "$1"; } +fail() { CHECKS=$((CHECKS + 1)); FAILURES=$((FAILURES + 1)); printf 'FAIL %s\n' "$1"; [ $# -lt 2 ] || printf ' %s\n' "$2"; } +note() { printf ' %s\n' "$1"; } + +echo "session-journal doctor — $TARGET" +echo + +# --- Where are the journals? ------------------------------------------------ + +SETTINGS="$TARGET/.claude/settings.json" +CODEX_SETTINGS="$TARGET/.codex/hooks.json" +SESSIONS_REL="sessions" +STOP_COMMAND="" +START_COMMAND="" + +read_command() { + local file=$1 hook=$2 + [ -e "$file" ] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + jq -r --arg hook "$hook" ' + [.hooks // {} | to_entries[] | .value[]? | .hooks[]? | .command // ""] + | map(select(contains($hook))) | first // "" + ' "$file" 2>/dev/null || printf '' +} + +for candidate_settings in "$SETTINGS" "$CODEX_SETTINGS"; do + [ -n "$START_COMMAND" ] || START_COMMAND=$(read_command "$candidate_settings" session-journal-pointer.sh) + [ -n "$STOP_COMMAND" ] || STOP_COMMAND=$(read_command "$candidate_settings" session-journal-nudge.sh) +done + +case "$STOP_COMMAND$START_COMMAND" in + *SESSION_JOURNAL_DIR=*) + SESSIONS_REL=$(printf '%s' "$STOP_COMMAND$START_COMMAND" \ + | sed -n 's|.*SESSION_JOURNAL_DIR="[^"]*/\([^"]*\)".*|\1|p' | head -n 1) + [ -n "$SESSIONS_REL" ] || SESSIONS_REL="sessions" + ;; +esac +SESSIONS_DIR="$TARGET/$SESSIONS_REL" + +# --- Installation ------------------------------------------------------------ + +if command -v jq >/dev/null 2>&1; then + pass "jq is available" +else + fail "jq is available" "settings cannot be inspected or merged without it" +fi + +if [ -e "$SETTINGS" ] || [ -e "$CODEX_SETTINGS" ]; then + for candidate_settings in "$SETTINGS" "$CODEX_SETTINGS"; do + [ -e "$candidate_settings" ] || continue + if ! command -v jq >/dev/null 2>&1; then continue; fi + if jq -e . "$candidate_settings" >/dev/null 2>&1; then + pass "${candidate_settings#"$TARGET"/} is valid JSON" + else + fail "${candidate_settings#"$TARGET"/} is valid JSON" + fi + done +else + fail "a settings file exists" "no .claude/settings.json and no .codex/hooks.json" +fi + +if [ -n "$START_COMMAND" ]; then + pass "a SessionStart hook is registered" +else + fail "a SessionStart hook is registered" "no command referencing session-journal-pointer.sh" +fi +if [ -n "$STOP_COMMAND" ]; then + pass "a Stop hook is registered" +else + fail "a Stop hook is registered" "no command referencing session-journal-nudge.sh" +fi + +POINTER="$TARGET/.claude/hooks/session-journal-pointer.sh" +NUDGE="$TARGET/.claude/hooks/session-journal-nudge.sh" +for script in "$POINTER" "$NUDGE"; do + label="${script#"$TARGET"/}" + if [ ! -e "$script" ]; then + fail "$label exists" + elif [ ! -x "$script" ]; then + fail "$label is executable" "chmod +x $script" + elif ! bash -n "$script" 2>/dev/null; then + fail "$label parses" + else + pass "$label is present, executable, and parses" + stamped=$(sed -n 's|.*session-journal \([0-9][0-9.]*\).*|\1|p' "$script" | head -n 1) + [ -z "$stamped" ] || note "version $stamped" + fi +done + +# The registered command must actually resolve. A hook installed into a +# subdirectory of a git repository writes a runtime-relative path that points +# somewhere that does not exist, and then fails silently forever. +if [ -n "$STOP_COMMAND" ]; then + # Resolve the command's path the way the runtime would, without eval: the + # generated command is "$(git rev-parse --show-toplevel ... || pwd)/", + # so recompute that root here and rejoin it with the literal tail. + RUNTIME_ROOT=$(git -C "$TARGET" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$TARGET") + resolved=$(printf '%s' "$STOP_COMMAND" | sed -n 's|.*)\(/[^"]*session-journal-nudge\.sh\)".*|\1|p' | head -n 1) + if [ -n "$resolved" ]; then + resolved="$RUNTIME_ROOT$resolved" + else + resolved=$(printf '%s' "$STOP_COMMAND" | sed -n 's|.*"\(/[^"]*session-journal-nudge\.sh\)".*|\1|p' | head -n 1) + fi + if [ -z "$resolved" ]; then + note "could not parse the Stop command path; skipping resolution check" + elif [ -x "$resolved" ]; then + pass "the registered Stop command resolves to an executable script" + else + fail "the registered Stop command resolves" "$resolved is missing or not executable" + fi +fi + +# --- Journal directory ------------------------------------------------------- + +if [ -d "$SESSIONS_DIR" ]; then + pass "journal directory exists ($SESSIONS_REL/)" +else + fail "journal directory exists" "$SESSIONS_DIR is missing" +fi +if [ -e "$SESSIONS_DIR/README.md" ]; then + pass "$SESSIONS_REL/README.md exists" +else + fail "$SESSIONS_REL/README.md exists" "agents have no local contract to follow" +fi +if [ -e "$SESSIONS_DIR/TEMPLATE.md" ]; then + pass "$SESSIONS_REL/TEMPLATE.md exists" +else + fail "$SESSIONS_REL/TEMPLATE.md exists" "the hooks tell agents to start from a template that is not installed" +fi + +if command -v git >/dev/null 2>&1 && git -C "$TARGET" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + if git -C "$TARGET" check-ignore -q "$SESSIONS_DIR" 2>/dev/null; then + note "journals are gitignored: continuity is local-only and teammates will not receive it" + else + pass "journals are tracked by git and will reach teammates" + fi + if [ -e "$SETTINGS" ] && git -C "$TARGET" check-ignore -q "$SETTINGS" 2>/dev/null; then + fail "the hook settings are committed" ".claude/settings.json is gitignored, so only this machine runs the hooks" + elif [ -e "$SETTINGS" ]; then + if [ -n "$(git -C "$TARGET" status --porcelain -- "$SETTINGS" "$POINTER" "$NUDGE" 2>/dev/null)" ]; then + fail "the installation is committed" "commit .claude/ so teammates get the same hooks" + else + pass "the installation is committed" + fi + fi +fi + +# --- Behaviour --------------------------------------------------------------- + +if [ -x "$POINTER" ]; then + if OUTPUT=$(CLAUDE_PROJECT_DIR="$TARGET" SESSION_JOURNAL_DIR="$SESSIONS_DIR" "$POINTER" 2>/dev/null); then + if command -v jq >/dev/null 2>&1 && ! printf '%s' "$OUTPUT" | jq -e '.hookSpecificOutput.hookEventName == "SessionStart"' >/dev/null 2>&1; then + fail "the pointer emits valid SessionStart JSON" + else + pass "the pointer emits valid SessionStart JSON" + case "$OUTPUT" in + *"No session journal exists"*) note "no journal yet: the next substantive session should create one" ;; + *"finished session"*) note "every journal is DONE: the next session starts fresh" ;; + *) note "$(printf '%s' "$OUTPUT" | sed -n 's|.*journal: \([^ .]*\.md\).*|pointing at \1|p' | head -n 1)" ;; + esac + fi + else + fail "the pointer runs cleanly" + fi +fi + +if [ -x "$NUDGE" ]; then + STATUS=0 + printf '%s' '{"stop_hook_active":true}' \ + | CLAUDE_PROJECT_DIR="$TARGET" SESSION_JOURNAL_DIR="$SESSIONS_DIR" "$NUDGE" >/dev/null 2>&1 || STATUS=$? + if [ "$STATUS" -eq 0 ]; then + pass "stop_hook_active suppresses the freshness nudge (no infinite loop)" + elif [ "$STATUS" -eq 2 ]; then + fail "stop_hook_active suppresses the freshness nudge" "the newest journal has secret-looking content; run the Stop hook directly to see the line numbers" + else + fail "the nudge runs cleanly" "exit status $STATUS" + fi + + # Only meaningful once a journal exists: with an empty directory the nudge + # exits 2 for the missing journal, which is not a secrets finding. + JOURNAL_COUNT=0 + for candidate in "$SESSIONS_DIR"/*.md; do + [ -e "$candidate" ] || continue + case "$(basename "$candidate")" in README.md|TEMPLATE.md) continue ;; esac + JOURNAL_COUNT=$((JOURNAL_COUNT + 1)) + done + if [ "$JOURNAL_COUNT" -eq 0 ]; then + note "no journal to scan for secrets yet" + else + STATUS=0 + printf '%s' '{"stop_hook_active":false}' \ + | CLAUDE_PROJECT_DIR="$TARGET" SESSION_JOURNAL_DIR="$SESSIONS_DIR" JOURNAL_FRESH_SECS=999999999 SESSION_JOURNAL_SKIP=1 "$NUDGE" >/dev/null 2>&1 || STATUS=$? + if [ "$STATUS" -eq 0 ]; then + pass "the newest journal is free of secret-looking content" + elif [ "$STATUS" -eq 2 ]; then + fail "the newest journal is free of secret-looking content" "run the Stop hook directly for the line numbers" + else + fail "the secrets guard runs cleanly" "exit status $STATUS" + fi + fi +fi + +# --- Instruction blocks ------------------------------------------------------ + +for instructions in "$TARGET/CLAUDE.md" "$TARGET/AGENTS.md"; do + [ -e "$instructions" ] || continue + label="${instructions#"$TARGET"/}" + begins=$(grep -c '' "$instructions" 2>/dev/null || true) + ends=$(grep -c '' "$instructions" 2>/dev/null || true) + if [ "$begins" -eq 0 ] && [ "$ends" -eq 0 ]; then + note "$label has no session-journal block" + elif [ "$begins" -eq "$ends" ]; then + pass "$label carries a balanced session-journal block" + else + fail "$label carries a balanced session-journal block" "$begins begin marker(s), $ends end marker(s)" + fi +done + +echo +if [ "$FAILURES" -eq 0 ]; then + echo "$CHECKS checks passed." + exit 0 +fi +echo "$FAILURES of $CHECKS checks failed." +exit 1 diff --git a/install.sh b/install.sh index 6c9251a..8f2e88d 100755 --- a/install.sh +++ b/install.sh @@ -4,7 +4,17 @@ set -e usage() { - echo "Usage: ./install.sh /path/to/target-repo [--sessions-dir path] [--codex] [--no-claude] [--uninstall]" >&2 + cat >&2 <<'USAGE' +Usage: ./install.sh /path/to/target-repo [options] + + --sessions-dir Journal directory, relative to the target repo (default: sessions) + --plan-pointer Default "Live plan pointer" for the generated README (default: none) + --codex Also install Codex hooks and the AGENTS.md block + --no-claude Skip Claude Code hooks and the CLAUDE.md block + --refresh-docs Regenerate the sessions README, TEMPLATE.md, and the + instruction blocks, discarding local adaptations + --uninstall Remove the owned hooks, scripts, and instruction blocks +USAGE exit 64 } @@ -12,8 +22,10 @@ usage() { TARGET=$1 shift SESSIONS_REL="sessions" +PLAN_POINTER="none" INSTALL_CODEX=false INSTALL_CLAUDE=true +REFRESH_DOCS=false UNINSTALL=false while [ "$#" -gt 0 ]; do @@ -23,8 +35,14 @@ while [ "$#" -gt 0 ]; do SESSIONS_REL=$2 shift 2 ;; + --plan-pointer) + [ "$#" -ge 2 ] || usage + PLAN_POINTER=$2 + shift 2 + ;; --codex) INSTALL_CODEX=true; shift ;; --no-claude) INSTALL_CLAUDE=false; shift ;; + --refresh-docs) REFRESH_DOCS=true; shift ;; --uninstall) UNINSTALL=true; shift ;; -h|--help) usage ;; *) echo "Unknown option: $1" >&2; usage ;; @@ -38,12 +56,24 @@ case "$SESSIONS_REL" in ;; esac +case "$PLAN_POINTER" in + *[\"\\]*|*"|"*) + echo "--plan-pointer must not contain quotes, backslashes, or pipes" >&2 + exit 64 + ;; +esac + [ -d "$TARGET" ] || { echo "Target directory does not exist: $TARGET" >&2; exit 66; } TARGET=$(cd "$TARGET" && pwd) +TARGET_REAL=$(cd "$TARGET" && pwd -P) SOURCE=$(cd "$(dirname "$0")" && pwd) +VERSION=$(cat "$SOURCE/VERSION" 2>/dev/null || printf 'unknown') command -v jq >/dev/null 2>&1 || { echo "jq is required to merge hook settings safely." >&2; exit 69; } +CLAUDE_SETTINGS="$TARGET/.claude/settings.json" +CODEX_SETTINGS="$TARGET/.codex/hooks.json" + validate_json_file() { local file=$1 [ ! -e "$file" ] || jq -e . "$file" >/dev/null 2>&1 || { @@ -52,63 +82,46 @@ validate_json_file() { } } -merge_hooks() { - local file=$1 - local runtime=$2 - local pointer_command=$3 - local nudge_command=$4 - local start_matcher="*" - [ "$runtime" = "codex" ] && start_matcher="startup|resume|clear|compact" - mkdir -p "$(dirname "$file")" - [ -e "$file" ] || printf '%s\n' '{}' > "$file" - local tmp="$file.session-journal.tmp" - jq --arg pointer "$pointer_command" --arg nudge "$nudge_command" --arg matcher "$start_matcher" ' - .hooks = (.hooks // {}) - | .hooks.SessionStart = ([.hooks.SessionStart[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-pointer.sh")) | not)] + [{matcher:$matcher,hooks:[{type:"command",command:$pointer}]}]) - | .hooks.Stop = ([.hooks.Stop[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-nudge.sh")) | not)] + [{hooks:[{type:"command",command:$nudge}]}]) - ' "$file" > "$tmp" - mv "$tmp" "$file" -} - -remove_hooks() { - local file=$1 - [ -e "$file" ] || return 0 - validate_json_file "$file" - local tmp="$file.session-journal.tmp" - jq ' - .hooks = (.hooks // {}) - | .hooks.SessionStart = [.hooks.SessionStart[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-pointer.sh")) | not)] - | .hooks.Stop = [.hooks.Stop[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-nudge.sh")) | not)] - ' "$file" > "$tmp" - mv "$tmp" "$file" -} - -strip_owned_block() { - local destination=$1 - local begins ends +sentinels_balanced() { + local destination=$1 begins ends + [ -e "$destination" ] || return 0 begins=$(grep -c '' "$destination" 2>/dev/null || true) ends=$(grep -c '' "$destination" 2>/dev/null || true) - if [ "$begins" -ne "$ends" ]; then + [ "$begins" -eq "$ends" ] || { echo "$destination has an unmatched session-journal sentinel; refusing to modify it." >&2 return 65 - fi + } +} + +strip_owned_block() { awk ' // { skipping=1; next } // { skipping=0; next } !skipping { print } - ' "$destination" + ' "$1" +} + +has_owned_block() { + [ -e "$1" ] && grep -q '' "$1" 2>/dev/null +} + +render_template() { + sed "s|{{SESSIONS_DIR}}|$SESSIONS_REL|g; s|{{DEFAULT_PLAN_POINTER}}|$PLAN_POINTER|g; s|{{TIMEZONE}}|$TIMEZONE|g; s|{{EVIDENCE_VOCABULARY}}|$EVIDENCE_VOCABULARY|g" "$1" } replace_owned_block() { - local destination=$1 - local template=$2 + local destination=$1 template=$2 label=$3 + if has_owned_block "$destination" && [ "$REFRESH_DOCS" = false ]; then + echo "Kept the existing session-journal block in $label (re-run with --refresh-docs to regenerate it)." + return 0 + fi local tmp="$destination.session-journal.tmp" [ -e "$destination" ] || : > "$destination" strip_owned_block "$destination" > "$tmp" while [ -s "$tmp" ] && [ "$(tail -c 1 "$tmp" | wc -l | tr -d ' ')" -eq 0 ]; do printf '\n' >> "$tmp"; break; done { printf '%s\n' '' - sed "s|{{SESSIONS_DIR}}|$SESSIONS_REL|g" "$template" + render_template "$template" printf '%s\n' '' } >> "$tmp" mv "$tmp" "$destination" @@ -122,46 +135,164 @@ remove_owned_block() { mv "$tmp" "$destination" } -CLAUDE_SETTINGS="$TARGET/.claude/settings.json" -CODEX_SETTINGS="$TARGET/.codex/hooks.json" +remove_hooks() { + local file=$1 + [ -e "$file" ] || return 0 + validate_json_file "$file" + local tmp="$file.session-journal.tmp" + jq ' + .hooks = (.hooks // {}) + | .hooks.SessionStart = [.hooks.SessionStart[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-pointer.sh")) | not)] + | .hooks.Stop = [.hooks.Stop[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-nudge.sh")) | not)] + ' "$file" > "$tmp" + mv "$tmp" "$file" +} if [ "$UNINSTALL" = true ]; then + # Refuse before touching anything, so a refusal never leaves a half state. + [ "$INSTALL_CLAUDE" = false ] || sentinels_balanced "$TARGET/CLAUDE.md" || exit 65 + [ "$INSTALL_CODEX" = false ] || sentinels_balanced "$TARGET/AGENTS.md" || exit 65 remove_hooks "$CLAUDE_SETTINGS" remove_hooks "$CODEX_SETTINGS" rm -f "$TARGET/.claude/hooks/session-journal-pointer.sh" "$TARGET/.claude/hooks/session-journal-nudge.sh" remove_owned_block "$TARGET/CLAUDE.md" remove_owned_block "$TARGET/AGENTS.md" - echo "Session-journal hooks and instruction blocks removed. Journals and their README were kept as project history." + echo "Session-journal hooks and instruction blocks removed. Journals, their README, and TEMPLATE.md were kept as project history." exit 0 fi +# --------------------------------------------------------------------------- +# Preflight. Every refusal happens here, before the first byte is written: the +# installer used to copy both hooks and merge them live into settings.json and +# THEN exit 65 on an unmatched sentinel, leaving enforcement wired up with no +# instruction block to explain it, plus a stray .tmp file in the repository. +# --------------------------------------------------------------------------- + [ "$INSTALL_CLAUDE" = false ] || validate_json_file "$CLAUDE_SETTINGS" [ "$INSTALL_CODEX" = false ] || validate_json_file "$CODEX_SETTINGS" +[ "$INSTALL_CLAUDE" = false ] || sentinels_balanced "$TARGET/CLAUDE.md" || exit 65 +[ "$INSTALL_CODEX" = false ] || sentinels_balanced "$TARGET/AGENTS.md" || exit 65 + +# The installed hook commands resolve their own root at runtime with +# `git rev-parse --show-toplevel`. Installing into a subdirectory of a git repo +# therefore writes a command that points at a path which does not exist, and the +# hooks fail silently forever. Catch it here instead. +if GIT_TOPLEVEL=$(git -C "$TARGET" rev-parse --show-toplevel 2>/dev/null); then + GIT_TOPLEVEL=$(cd "$GIT_TOPLEVEL" && pwd -P) + if [ "$GIT_TOPLEVEL" != "$TARGET_REAL" ]; then + echo "$TARGET is inside a git repository but is not its root ($GIT_TOPLEVEL)." >&2 + echo "The installed hooks resolve their root with 'git rev-parse --show-toplevel' at runtime, so they would look for themselves in the wrong place. Install into the repository root instead." >&2 + exit 65 + fi +else + echo "Note: $TARGET is not a git repository. The hook commands fall back to the agent's working directory at runtime; start the agent from the repository root." >&2 +fi + +# A symlinked journal directory that escapes the repository silently makes +# journals unreviewable: they stop being part of the project's git history. +SESSIONS_ABS="$TARGET/$SESSIONS_REL" +if [ -e "$SESSIONS_ABS" ]; then + SESSIONS_REAL=$(cd "$SESSIONS_ABS" 2>/dev/null && pwd -P || printf '') + case "$SESSIONS_REAL" in + "$TARGET_REAL"|"$TARGET_REAL"/*) ;; + *) + echo "$SESSIONS_ABS resolves to $SESSIONS_REAL, outside $TARGET_REAL; refusing to install journals outside the repository." >&2 + exit 65 + ;; + esac +fi + +# --------------------------------------------------------------------------- +# Writes start here. +# --------------------------------------------------------------------------- -mkdir -p "$TARGET/.claude/hooks" "$TARGET/$SESSIONS_REL" -cp "$SOURCE/hooks/session-journal-pointer.sh" "$TARGET/.claude/hooks/session-journal-pointer.sh" -cp "$SOURCE/hooks/session-journal-nudge.sh" "$TARGET/.claude/hooks/session-journal-nudge.sh" -chmod +x "$TARGET/.claude/hooks/session-journal-pointer.sh" "$TARGET/.claude/hooks/session-journal-nudge.sh" TIMEZONE=$(date +%Z 2>/dev/null || printf '%s' local) -sed "s|{{SESSIONS_DIR}}|$SESSIONS_REL|g; s|{{DEFAULT_PLAN_POINTER}}|none|g; s|{{TIMEZONE}}|$TIMEZONE|g; s|{{EVIDENCE_VOCABULARY}}|commit hashes, test names, issue IDs, and document section references|g" \ - "$SOURCE/templates/sessions-README.md" > "$TARGET/$SESSIONS_REL/README.md" +EVIDENCE_VOCABULARY="commit hashes, test names, issue IDs, and document section references" + +mkdir -p "$TARGET/.claude/hooks" "$SESSIONS_ABS" + +install_hook_script() { + local name=$1 + sed "s||$VERSION|" "$SOURCE/hooks/$name" > "$TARGET/.claude/hooks/$name" + chmod +x "$TARGET/.claude/hooks/$name" +} +install_hook_script session-journal-pointer.sh +install_hook_script session-journal-nudge.sh + +# The sessions README is the adopting agent's artifact: ADOPT.md spends its +# question budget adapting it to the repository. Regenerating it on every run +# made the only available upgrade path silently revert that work. +if [ -e "$SESSIONS_ABS/README.md" ] && [ "$REFRESH_DOCS" = false ]; then + echo "Kept the existing $SESSIONS_REL/README.md (re-run with --refresh-docs to regenerate it)." +else + render_template "$SOURCE/templates/sessions-README.md" > "$SESSIONS_ABS/README.md" +fi + +# Ship the journal shape itself. Without this, both the pointer hook and the +# generated README told a fresh agent to start from a template that the +# installer never placed, so every first journal was written from memory. +if [ -e "$SESSIONS_ABS/TEMPLATE.md" ] && [ "$REFRESH_DOCS" = false ]; then + echo "Kept the existing $SESSIONS_REL/TEMPLATE.md (re-run with --refresh-docs to regenerate it)." +else + render_template "$SOURCE/templates/session-journal.md" > "$SESSIONS_ABS/TEMPLATE.md" +fi RUNTIME_ROOT="\$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -ENV_PREFIX="" -[ "$SESSIONS_REL" = "sessions" ] || ENV_PREFIX="SESSION_JOURNAL_DIR=\"$RUNTIME_ROOT/$SESSIONS_REL\" " -POINTER_COMMAND="${ENV_PREFIX}\"$RUNTIME_ROOT/.claude/hooks/session-journal-pointer.sh\"" -NUDGE_COMMAND="${ENV_PREFIX}\"$RUNTIME_ROOT/.claude/hooks/session-journal-nudge.sh\"" +GENERATED_PREFIX="" +[ "$SESSIONS_REL" = "sessions" ] || GENERATED_PREFIX="SESSION_JOURNAL_DIR=\"$RUNTIME_ROOT/$SESSIONS_REL\" " + +# ADOPT.md Phase B tells the adopting agent to add environment overrides such as +# JOURNAL_FRESH_SECS to the generated command. Rebuilding the command from +# scratch deleted them on every re-run, so carry any foreign assignments across. +preserved_env() { + local file=$1 hook=$2 existing + [ -e "$file" ] || return 0 + existing=$(jq -r --arg hook "$hook" ' + [.hooks // {} | to_entries[] | .value[]? | .hooks[]? | .command // ""] + | map(select(contains($hook))) | first // "" + ' "$file" 2>/dev/null || printf '') + [ -n "$existing" ] || return 0 + printf '%s' "$existing" \ + | sed 's/"[^"]*"//g' \ + | tr ' ' '\n' \ + | grep -E '^[A-Za-z_][A-Za-z0-9_]*=.' \ + | grep -v '^SESSION_JOURNAL_DIR=' \ + | tr '\n' ' ' +} + +merge_hooks() { + local file=$1 runtime=$2 start_matcher="*" + [ "$runtime" = "codex" ] && start_matcher="startup|resume|clear|compact" + mkdir -p "$(dirname "$file")" + [ -e "$file" ] || printf '%s\n' '{}' > "$file" + + local pointer_env nudge_env pointer_command nudge_command + pointer_env=$(preserved_env "$file" session-journal-pointer.sh) + nudge_env=$(preserved_env "$file" session-journal-nudge.sh) + [ -z "$pointer_env" ] || echo "Preserved environment overrides on the SessionStart command: $pointer_env" + [ -z "$nudge_env" ] || echo "Preserved environment overrides on the Stop command: $nudge_env" + pointer_command="${pointer_env}${GENERATED_PREFIX}\"$RUNTIME_ROOT/.claude/hooks/session-journal-pointer.sh\"" + nudge_command="${nudge_env}${GENERATED_PREFIX}\"$RUNTIME_ROOT/.claude/hooks/session-journal-nudge.sh\"" + + local tmp="$file.session-journal.tmp" + jq --arg pointer "$pointer_command" --arg nudge "$nudge_command" --arg matcher "$start_matcher" ' + .hooks = (.hooks // {}) + | .hooks.SessionStart = ([.hooks.SessionStart[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-pointer.sh")) | not)] + [{matcher:$matcher,hooks:[{type:"command",command:$pointer}]}]) + | .hooks.Stop = ([.hooks.Stop[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-nudge.sh")) | not)] + [{hooks:[{type:"command",command:$nudge}]}]) + ' "$file" > "$tmp" + mv "$tmp" "$file" +} if [ "$INSTALL_CLAUDE" = true ]; then - merge_hooks "$CLAUDE_SETTINGS" claude "$POINTER_COMMAND" "$NUDGE_COMMAND" - replace_owned_block "$TARGET/CLAUDE.md" "$SOURCE/templates/CLAUDE.md-section.md" + merge_hooks "$CLAUDE_SETTINGS" claude + replace_owned_block "$TARGET/CLAUDE.md" "$SOURCE/templates/CLAUDE.md-section.md" CLAUDE.md fi if [ "$INSTALL_CODEX" = true ]; then - merge_hooks "$CODEX_SETTINGS" codex "$POINTER_COMMAND" "$NUDGE_COMMAND" - replace_owned_block "$TARGET/AGENTS.md" "$SOURCE/templates/AGENTS.md-section.md" + merge_hooks "$CODEX_SETTINGS" codex + replace_owned_block "$TARGET/AGENTS.md" "$SOURCE/templates/AGENTS.md-section.md" AGENTS.md fi -echo "Session journal installed at $SESSIONS_REL/. Run the verification steps in ADOPT.md Phase C before relying on it." +echo "Session journal $VERSION installed at $SESSIONS_REL/. Run the verification steps in ADOPT.md Phase C, or ./doctor.sh $TARGET, before relying on it." if [ "$INSTALL_CODEX" = true ]; then echo "Codex: trust this project and review the new hook definitions with /hooks before expecting them to run." fi diff --git a/templates/session-journal.md b/templates/session-journal.md index b36315b..dfb984b 100644 --- a/templates/session-journal.md +++ b/templates/session-journal.md @@ -1,14 +1,14 @@ # Session: -**Started:** YYYY-MM-DD HH:MM -**Last updated:** YYYY-MM-DD HH:MM +**Started:** YYYY-MM-DD HH:MM {{TIMEZONE}} +**Last updated:** YYYY-MM-DD HH:MM {{TIMEZONE}} **Status:** ACTIVE | DONE | BLOCKED — one current-state line ## User intent ## Live plan pointer -none +{{DEFAULT_PLAN_POINTER}} ## Milestones - YYYY-MM-DD HH:MM — @@ -17,7 +17,11 @@ none - ## Verification evidence -- — +" stays true; "38 of 38 passed" silently rots the moment the suite grows, +and the next agent will believe it.> +- — ## Commits made - , or `none` @@ -29,7 +33,9 @@ none ## Next step for a fresh agent - + ## Open questions / loose ends - diff --git a/templates/sessions-README.md b/templates/sessions-README.md index d6fa0e7..b3370e7 100644 --- a/templates/sessions-README.md +++ b/templates/sessions-README.md @@ -11,7 +11,7 @@ This repository keeps one markdown journal per substantive working session in `{ ## Filename -Use `YYYY-MM-DD-HHMM-.md` with the session-start time. +Use `YYYY-MM-DD-HHMM-.md` with the session-start time. Start from `{{SESSIONS_DIR}}/TEMPLATE.md`. ## THIN and DETAILED modes @@ -25,11 +25,16 @@ Use `YYYY-MM-DD-HHMM-.md` with the session-start time. - After context compaction: read the newest journal and its live plan before acting, then refresh the journal if the compaction changed the next step. - Keep `**Status:**` to one line; move state history to `## Change log`. - Put updates in their proper sections, not at the end of the file. +- Set `**Status:** DONE` when the work is finished. A DONE journal is treated as background, not as the live frontier, so the next session starts clean instead of inheriting a finished next step. + +## Sessions that need no journal + +A typo fix, a dependency bump, or a question answered without changing anything does not need a journal. Set `SESSION_JOURNAL_SKIP=1` for that session instead of writing a journal about nothing or working around the Stop hook. The secrets guard still runs. ## Content contract Allowed: concise milestones, decisions, verification evidence, commit references, issue IDs, and artifact pointers. -Never include credentials, credential fragments, private tokens, large logs, duplicated plan prose, or speculation presented as fact. The Stop hook scans the newest journal for common secret shapes. A false-positive line may carry `` only after a human verifies that the line contains no credential value. +Prefer evidence that can be re-run over a number captured at write time. `bats tests — 41 checks, see CI run ` can be checked by the next reader; `38 of 38 pass` becomes false the moment the suite grows, and the next agent will believe it and repeat it. -Start from the repository's installed journal template or from `templates/session-journal.md` in the upstream project. +Never include credentials, credential fragments, private tokens, large logs, duplicated plan prose, or speculation presented as fact. The Stop hook scans the newest journal for common secret shapes. Redact a value to a key name, an env-var reference, or a placeholder such as ``. A remaining false positive may carry `` only after a human verifies that the line contains no credential value. diff --git a/tests/doctor.bats b/tests/doctor.bats new file mode 100644 index 0000000..2dbf012 --- /dev/null +++ b/tests/doctor.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + setup_workspace + export TARGET="$TEST_ROOT/target" + mkdir -p "$TARGET" + git -C "$TARGET" init -q + git -C "$TARGET" config user.email journal@test.invalid + git -C "$TARGET" config user.name "Journal Test" +} +teardown() { teardown_workspace; } + +commit_target() { + git -C "$TARGET" add -A >/dev/null + git -C "$TARGET" commit -qm "${1:-adopt}" +} + +@test "a healthy committed installation passes every check" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + commit_target + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 0 ] + [[ "$output" == *"checks passed."* ]] + [[ "$output" != *"FAIL"* ]] +} + +@test "a missing hook script is reported" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + commit_target + rm -f "$TARGET/.claude/hooks/session-journal-nudge.sh" + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 1 ] + [[ "$output" == *"FAIL"* ]] + [[ "$output" == *"session-journal-nudge.sh exists"* ]] +} + +@test "an uninstalled repository is reported rather than passing quietly" { + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 1 ] + [[ "$output" == *"a SessionStart hook is registered"* ]] + [[ "$output" == *"a Stop hook is registered"* ]] +} + +@test "hooks that git will never share with a teammate are reported" { + printf '.claude/\n' > "$TARGET/.gitignore" + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + commit_target + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 1 ] + [[ "$output" == *"gitignored"* ]] +} + +@test "an uncommitted installation is reported" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 1 ] + [[ "$output" == *"the installation is committed"* ]] +} + +@test "a journal carrying a secret is reported" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + commit_target + printf '**Status:** ACTIVE\n\n## Live plan pointer\nnone\n\n## Next step for a fresh agent\nx\napi_key = sk-live-4eC39HqLyjWDarjtT1z\n' \ + > "$TARGET/sessions/2026-01-02-0304-leak.md" + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 1 ] + [[ "$output" == *"free of secret-looking content"* ]] + [[ "$output" != *"sk-live"* ]] +} + +@test "the doctor reports which journal the pointer would deliver" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + commit_target + printf '**Status:** ACTIVE\n\n## Live plan pointer\nnone\n\n## Next step for a fresh agent\nx\n' \ + > "$TARGET/sessions/2026-01-02-0304-live.md" + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [[ "$output" == *"2026-01-02-0304-live.md"* ]] +} + +@test "an unmatched instruction sentinel is reported" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + commit_target + printf '\n\n' >> "$TARGET/CLAUDE.md" + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 1 ] + [[ "$output" == *"balanced session-journal block"* ]] +} diff --git a/tests/install.bats b/tests/install.bats index 0f7225b..6d509c0 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -107,3 +107,101 @@ teardown() { teardown_workspace; } run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --codex --uninstall [ "$status" -eq 0 ] } + +@test "the journal template is installed so the hooks' instructions resolve" { + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 0 ] + [ -f "$TARGET/sessions/TEMPLATE.md" ] + grep -Fq '## Next step for a fresh agent' "$TARGET/sessions/TEMPLATE.md" + grep -Fq 'TEMPLATE.md' "$TARGET/sessions/README.md" +} + +@test "the installed hooks carry a resolved version stamp" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + version="$(cat "$BATS_TEST_DIRNAME/../VERSION")" + grep -Fq "session-journal $version" "$TARGET/.claude/hooks/session-journal-pointer.sh" + grep -Fq "session-journal $version" "$TARGET/.claude/hooks/session-journal-nudge.sh" + ! grep -Fq '' "$TARGET/.claude/hooks/session-journal-nudge.sh" +} + +@test "rerunning preserves the adapted sessions README" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + printf '%s\n' '- Local rule: cite the GATE-000 id.' >> "$TARGET/sessions/README.md" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 0 ] + grep -Fq 'GATE-000' "$TARGET/sessions/README.md" +} + +@test "rerunning preserves an adapted instruction block" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + awk '{print} //{print "Local rule: link the tracker id."}' \ + "$TARGET/CLAUDE.md" > "$TARGET/CLAUDE.md.new" + mv "$TARGET/CLAUDE.md.new" "$TARGET/CLAUDE.md" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 0 ] + grep -Fq 'Local rule: link the tracker id.' "$TARGET/CLAUDE.md" + [ "$(grep -c '' "$TARGET/CLAUDE.md")" -eq 1 ] +} + +@test "rerunning preserves environment overrides on the generated commands" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + jq '.hooks.Stop[0].hooks[0].command = ("JOURNAL_FRESH_SECS=900 " + .hooks.Stop[0].hooks[0].command)' \ + "$TARGET/.claude/settings.json" > "$TARGET/settings.new" + mv "$TARGET/settings.new" "$TARGET/.claude/settings.json" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 0 ] + jq -e '.hooks.Stop[0].hooks[0].command | startswith("JOURNAL_FRESH_SECS=900 ")' "$TARGET/.claude/settings.json" >/dev/null + [ "$(jq '.hooks.Stop | length' "$TARGET/.claude/settings.json")" -eq 1 ] +} + +@test "--refresh-docs regenerates what a plain rerun preserves" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + printf '%s\n' '- Local rule: cite the GATE-000 id.' >> "$TARGET/sessions/README.md" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --refresh-docs + [ "$status" -eq 0 ] + ! grep -Fq 'GATE-000' "$TARGET/sessions/README.md" +} + +@test "--plan-pointer makes the deterministic install agree with ADOPT.md about THIN mode" { + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --plan-pointer docs/plans/release.md + [ "$status" -eq 0 ] + grep -Fq 'docs/plans/release.md' "$TARGET/sessions/README.md" + grep -Fq 'docs/plans/release.md' "$TARGET/sessions/TEMPLATE.md" +} + +@test "an unmatched sentinel refuses before anything is written" { + printf '# Project\n\n\nstray\n' > "$TARGET/CLAUDE.md" + before="$(cksum < "$TARGET/CLAUDE.md")" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 65 ] + [ ! -e "$TARGET/.claude/settings.json" ] + [ ! -e "$TARGET/.claude/hooks/session-journal-nudge.sh" ] + [ ! -e "$TARGET/sessions/README.md" ] + [ -z "$(find "$TARGET" -name '*.session-journal.tmp')" ] + [ "$(cksum < "$TARGET/CLAUDE.md")" = "$before" ] +} + +@test "installing into a subdirectory of a git repository is refused" { + mkdir -p "$TARGET/nested" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET/nested" + [ "$status" -eq 65 ] + [[ "$output" == *"not its root"* ]] + [ ! -e "$TARGET/nested/.claude/settings.json" ] +} + +@test "a journal directory that escapes the repository is refused" { + mkdir -p "$TEST_ROOT/outside" + ln -s "$TEST_ROOT/outside" "$TARGET/journals" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --sessions-dir journals + [ "$status" -eq 65 ] + [ -z "$(find "$TEST_ROOT/outside" -type f)" ] +} + +@test "uninstall refuses on an unmatched sentinel before removing hooks" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + printf '\n\n' >> "$TARGET/CLAUDE.md" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --uninstall + [ "$status" -eq 65 ] + [ -x "$TARGET/.claude/hooks/session-journal-nudge.sh" ] + [ "$(jq '.hooks.Stop | length' "$TARGET/.claude/settings.json")" -eq 1 ] +} From afb51d6337ba9aae89e200aecbcedaad2e2d24c9 Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Thu, 27 Aug 2026 02:56:57 +0530 Subject: [PATCH 03/14] docs: state the selection rule, hold the prose under test, and dogfood the discipline The project's own evidence artifact had already drifted: docs/verification.md claimed "38/38" in three places while the suite had grown past it. That number came from a journal line the next agent believed and repeated -- which is the compounding failure mode of a journaling product, and one no amount of shell hardening fixes. The record now separates a dated adoption snapshot, which is never rewritten, from live gates that tests/docs.bats holds to the code: the stated bats count, the documented environment variables, the documented installer flags, and the spec/hook agreement about DONE. The specification gains a "Selecting the live journal" section. Selection was the single most consequential behaviour in the product and the only one left entirely to the implementation, which is how it managed to be wrong in two independent ways at once. Evidence guidance is now explicit: cite something a reader can re-run, because a count frozen at write time rots and the journal is the highest-trust artifact a fresh agent reads. Dogfooding: this repository now has the discipline installed on itself. It claimed to dogfood while carrying one journal from launch day and no hooks, so commit 0016aa6 changed hook behaviour here with no journal written -- the Stop hook was either bypassed or not firing in the maintainer's own repo. Installing it settles that, and turns CI's ./doctor.sh step into a real check. CI: actions pinned by commit SHA and verified against their tags (the gitleaks tarball was already SHA-pinned, so a mutable action ref was the weaker link); a bash 3.2 leg, because that compatibility claim was the one thing untested and the selection bug lived precisely in bash 3.2's whole-second -nt comparison; and the secrets gate now walks every tracked journal instead of only the newest, which its own step name always claimed it did. Adds CHANGELOG.md with upgrade notes and SECURITY.md, which a project that copies executable shell into a stranger's repository and wires it into two lifecycle events should have had from the start. Tests: 89 -> 95 checks. --- .claude/hooks/session-journal-nudge.sh | 250 ++++++++++++++++++ .claude/hooks/session-journal-pointer.sh | 154 +++++++++++ .claude/settings.json | 25 ++ .github/workflows/ci.yml | 53 +++- CHANGELOG.md | 117 ++++++++ CLAUDE.md | 9 + README.md | 85 +++++- SECURITY.md | 63 +++++ docs/verification.md | 32 ++- .../2026-08-27-0130-operate-the-discipline.md | 88 ++++++ sessions/TEMPLATE.md | 44 +++ spec/session-journal-spec.md | 27 +- tests/docs.bats | 51 ++++ 13 files changed, 974 insertions(+), 24 deletions(-) create mode 100755 .claude/hooks/session-journal-nudge.sh create mode 100755 .claude/hooks/session-journal-pointer.sh create mode 100644 .claude/settings.json create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 SECURITY.md create mode 100644 sessions/2026-08-27-0130-operate-the-discipline.md create mode 100644 sessions/TEMPLATE.md create mode 100644 tests/docs.bats diff --git a/.claude/hooks/session-journal-nudge.sh b/.claude/hooks/session-journal-nudge.sh new file mode 100755 index 0000000..f9097f9 --- /dev/null +++ b/.claude/hooks/session-journal-nudge.sh @@ -0,0 +1,250 @@ +#!/bin/bash +# Stop hook: enforce a fresh session journal and reject secret-looking content. +# +# Claude Code and Codex pass JSON on stdin. stop_hook_active prevents a second +# freshness block, while the secrets guard always runs. Some runners provide no +# stdin; for those only, a short-lived marker supplies one-shot loop protection. +# +# session-journal 1.1.0 — https://github.com/dctmfoo/session-journal (MIT) + +set -e + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +SESSIONS_DIR="${SESSION_JOURNAL_DIR:-$PROJECT_DIR/sessions}" +case "$SESSIONS_DIR" in + /*) ;; + *) SESSIONS_DIR="$PROJECT_DIR/$SESSIONS_DIR" ;; +esac + +FRESH_SECS="${JOURNAL_FRESH_SECS:-300}" +case "$FRESH_SECS" in + ''|*[!0-9]*) + echo "SESSION-JOURNAL: JOURNAL_FRESH_SECS='$FRESH_SECS' is not a whole number of seconds; using 300." >&2 + FRESH_SECS=300 + ;; +esac + +# One-shot markers exist to break a stop cascade within a single session. Without +# a TTL the marker outlives the session that armed it and silently swallows the +# next session's first nudge, so anything older than this is treated as stale. +MARKER_TTL="${JOURNAL_STOP_MARKER_TTL_SECS:-60}" +case "$MARKER_TTL" in + ''|*[!0-9]*) MARKER_TTL=60 ;; +esac + +# Read the WHOLE stdin payload, not just its first line. A single `read` stops +# at the first newline, so a runner that pretty-prints its hook JSON would hand +# us "{", which defeats the stop_hook_active check AND suppresses the marker +# fallback below (because the payload is non-empty), leaving no loop protection +# at all. The per-read timeout is kept: some runners attach a stdin they never +# close, and a plain `cat` would hang the Stop hook forever. +INPUT="" +STDIN_LINE="" +while IFS= read -r -t 2 STDIN_LINE; do + INPUT="$INPUT$STDIN_LINE +" + STDIN_LINE="" +done +[ -z "$STDIN_LINE" ] || INPUT="$INPUT$STDIN_LINE" + +STOP_ACTIVE=false +if [ -n "$INPUT" ]; then + if command -v python3 >/dev/null 2>&1; then + STOP_ACTIVE=$(printf '%s' "$INPUT" | python3 -c 'import json,sys +try: print("true" if json.load(sys.stdin).get("stop_hook_active") is True else "false") +except Exception: print("false")' 2>/dev/null || printf false) + elif command -v jq >/dev/null 2>&1; then + STOP_ACTIVE=$(printf '%s' "$INPUT" | jq -r 'if .stop_hook_active == true then "true" else "false" end' 2>/dev/null || printf false) + elif printf '%s' "$INPUT" | tr '\n' ' ' | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then + STOP_ACTIVE=true + fi +fi + +MARKER="" +if [ -z "$INPUT" ]; then + PROJECT_KEY=$(printf '%s' "$PROJECT_DIR" | cksum | awk '{print $1}') + MARKER="${TMPDIR:-/tmp}/session-journal-nudge-$PROJECT_KEY.d" + if [ -d "$MARKER" ]; then + MARKER_MTIME="" + MARKER_MTIME=$(stat -f %m "$MARKER" 2>/dev/null || stat -c %Y "$MARKER" 2>/dev/null || printf '') + rmdir "$MARKER" 2>/dev/null || true + if [ -n "$MARKER_MTIME" ] && [ "$(( $(date +%s) - MARKER_MTIME ))" -le "$MARKER_TTL" ]; then + STOP_ACTIVE=true + fi + fi +fi + +# A Stop hook is not an installer: it must not create the sessions directory as +# a side effect. The glob below handles a nonexistent directory correctly. + +# Newest journal: mtime leads, filename settles ties. mtime alone is wrong +# whenever several journals share a timestamp -- a fresh `git clone`, a branch +# checkout, an unpacked archive -- because bash 3.2 compares `-nt` at +# whole-second resolution and the tie keeps the alphabetically OLDEST file. +LATEST="" +LATEST_BASE="" +for candidate in "$SESSIONS_DIR"/*.md; do + [ -e "$candidate" ] || continue + base=$(basename "$candidate") + case "$base" in README.md|TEMPLATE.md) continue ;; esac + if [ -z "$LATEST" ]; then + LATEST=$candidate + LATEST_BASE=$base + elif [ "$candidate" -nt "$LATEST" ]; then + LATEST=$candidate + LATEST_BASE=$base + elif [ ! "$LATEST" -nt "$candidate" ] && [ "$base" \> "$LATEST_BASE" ]; then + LATEST=$candidate + LATEST_BASE=$base + fi +done + +# The allowlist applies only to the line carrying the marker. Values are never +# echoed back: diagnostics identify line numbers so a hook cannot leak a secret. +if [ -n "$LATEST" ]; then + CANDIDATES=$(grep -nEv '' "$LATEST" 2>/dev/null || true) + + # Credential shapes that carry their own prefix are unambiguous: a JWT, a + # bearer value, an AWS key id, a GitHub or Slack token, a private-key header. + NAMED=$(printf '%s\n' "$CANDIDATES" \ + | grep -Ei 'e[y]J[A-Za-z0-9_-]{20,}|Bearer[[:space:]]+[A-Za-z0-9._-]{16,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{16,}|-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----' || true) + + # `key: value` assignments need the VALUE inspected, not just the key name. + # The old pattern flagged any 6+ non-space token after a credential key, which + # blocked `password: ` and `token: stored-in-ci-secrets` -- the exact + # remediations this hook's own error message prescribes, producing a block -> + # redact -> block loop the runtime cannot break. A value is now reported only + # when it is long enough to be a credential, drawn from a credential alphabet, + # and not a placeholder -- either a leading symbol (, $ENV_VAR) or a + # leading word that describes where the secret lives (stored-in-ci-secrets). + ASSIGNED=$(awk ' + function is_placeholder(v, head) { + if (v ~ /^[<${*("'"'"'\[]/) return 1 + head = v + sub(/[-_.:].*$/, "", head) + return (head ~ /^(redacted|redact|redacts|placeholder|example|sample|dummy|fake|test|none|null|nil|unset|empty|blank|omitted|elided|scrubbed|masked|hidden|removed|rotated|expired|revoked|sanitized|sanitised|todo|tbd|changeme|see|stored|store|kept|held|managed|provided|supplied|injected|configured|set|use|using|from|in|via|your|my|the|not|no|never|only|env|vault|keychain|n)$/) + } + { + if ($0 ~ //) next + line = $0 + if (match(tolower(line), /(client[_-]?secret|api[_-]?key|secret[_-]?key|access[_-]?key|password|passphrase|token)[[:space:]]*[:=][[:space:]]*/) == 0) next + value = substr(line, RSTART + RLENGTH) + sub(/[[:space:]].*$/, "", value) + sub(/[",;.)]+$/, "", value) + if (length(value) < 12) next + if (value !~ /^[A-Za-z0-9._~+\/=-]+$/) next + if (is_placeholder(tolower(value))) next + print NR ":" line + } + ' "$LATEST" 2>/dev/null || true) + + # A bare long hex string is only suspicious away from digest context. SHA-256 + # artifact receipts, git revisions, and fingerprints are this program's + # evidence, not secrets, and must survive verbatim to stay verifiable. + # + # Context is read over a three-line window, NOT the hash's own line. Prose + # wraps, and a 64-character digest routinely lands on a line of its own with + # "hashes to" or "SHA-256" ending the line above; a single-line check sees a + # bare hex string and cries wolf. + # + # Every non-alphanumeric run collapses to a single space before matching, so + # the keywords compare as whole words. Substring matching let "Avoid", + # "Android" and "paranoid" satisfy `oid`, and "committed" satisfy `commit`, + # silently disabling hex detection for their neighbouring lines. + BARE_HEX=$(awk ' + { lines[NR] = $0 } + END { + for (i = 1; i <= NR; i++) { + if (lines[i] ~ //) continue + ctx = " " tolower((i > 1 ? lines[i-1] : "") " " lines[i] " " (i < NR ? lines[i+1] : "")) " " + gsub(/[^a-z0-9]+/, " ", ctx) + if (ctx ~ / (sha|sha1|sha256|sha384|sha512|hash|hashes|hashed|digest|digests|checksum|checksums|fingerprint|fingerprints|commit|commits|revision|revisions|blob|blobs|oid|oids|etag|etags) /) continue + print i ":" lines[i] + } + }' "$LATEST" 2>/dev/null | grep -Ei '[0-9a-f]{40,}' || true) + + LEAK_LINES=$(printf '%s\n%s\n%s\n' "$NAMED" "$ASSIGNED" "$BARE_HEX" \ + | grep -E '^[0-9]+:' | cut -d: -f1 | sort -n -u | head -n 3 | paste -sd, - || true) + if [ -n "$LEAK_LINES" ]; then + echo "SESSION-JOURNAL-SECRETS-GUARD: secret-looking content found in $LATEST at line(s) $LEAK_LINES (first three shown). Replace the value with a key name, an env-var reference, or a placeholder such as . If a human has confirmed the line carries no credential value, append to that line to exempt it." >&2 + exit 2 + fi +fi + +if [ "$STOP_ACTIVE" = "true" ]; then + printf '{}\n' + exit 0 +fi + +# A trivial session -- a typo fix, a dependency bump -- does not need a journal. +# Without a documented escape the only ways out are to burn stop_hook_active or +# to write a journal about nothing, and both teach the agent to route around the +# discipline. The secrets guard above deliberately still runs. +case "${SESSION_JOURNAL_SKIP:-}" in + ''|0|false|no) ;; + *) printf '{}\n'; exit 0 ;; +esac + +block_once() { + [ -z "$MARKER" ] || mkdir "$MARKER" 2>/dev/null || true + echo "$1" >&2 + exit 2 +} + +if [ -z "$LATEST" ]; then + block_once "SESSION-JOURNAL-REMINDER: No session journal exists. Create YYYY-MM-DD-HHMM-.md from the local template before finishing resumable work. If this session needs no journal, set SESSION_JOURNAL_SKIP=1." +fi + +if ! MTIME=$(stat -f %m "$LATEST" 2>/dev/null); then + MTIME=$(stat -c %Y "$LATEST" 2>/dev/null || printf '0') +fi +NOW=$(date +%s) +AGE=$((NOW - MTIME)) + +PLAN_LINE=$(awk ' + tolower($0) ~ /^##[[:space:]]+live plan pointer[[:space:]]*$/ { inside=1; next } + inside && /^## / { exit } + inside && NF { print; exit } +' "$LATEST" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' || true) +PLAN_LOWER=$(printf '%s' "$PLAN_LINE" | tr '[:upper:]' '[:lower:]') + +mode_reminder() { + case "$PLAN_LOWER" in + ""|none|\*) + block_once "SESSION-JOURNAL-REMINDER (DETAILED mode): $1 Update milestones, files, current state, next step, and Last updated; promote recurring work into a durable plan and switch to THIN." + ;; + *) + block_once "SESSION-JOURNAL-REMINDER (THIN mode — pointer: $PLAN_LINE): $1 Add one-line evidence and refresh current state, next step, and Last updated without duplicating the governing document." + ;; + esac +} + +# Freshness by mtime alone measures a property the agent can set directly: a +# bare `touch` satisfies the gate with zero characters changed. When git can +# tell us whether the journal actually moved, require content, not a timestamp. +journal_records_work() { + local journal=$1 dirty journal_dirty + case "${JOURNAL_REQUIRE_CONTENT_CHANGE:-1}" in + 0|false|no) return 0 ;; + esac + command -v git >/dev/null 2>&1 || return 0 + git -C "$PROJECT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + # Local-only journals are invisible to `git status`; fall back to mtime. + if git -C "$PROJECT_DIR" check-ignore -q "$journal" 2>/dev/null; then return 0; fi + dirty=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null | head -n 200 || true) + # Nothing changed anywhere: there is no work for the journal to record. + [ -n "$dirty" ] || return 0 + journal_dirty=$(git -C "$PROJECT_DIR" status --porcelain -- "$journal" 2>/dev/null || true) + [ -z "$journal_dirty" ] || return 0 + return 1 +} + +if [ "$AGE" -lt "$FRESH_SECS" ]; then + if journal_records_work "$LATEST"; then + printf '{}\n' + exit 0 + fi + mode_reminder "the newest journal's timestamp moved but its content did not, while other files in this repository did change." +fi + +mode_reminder "the newest journal is ${AGE}s old." diff --git a/.claude/hooks/session-journal-pointer.sh b/.claude/hooks/session-journal-pointer.sh new file mode 100755 index 0000000..ee4735d --- /dev/null +++ b/.claude/hooks/session-journal-pointer.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# SessionStart hook: inject a compact pointer to the live session journal. +# Compatible with bash 3.2 and with Claude Code and Codex command hooks. +# +# session-journal 1.1.0 — https://github.com/dctmfoo/session-journal (MIT) + +set -e + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +SESSIONS_DIR="${SESSION_JOURNAL_DIR:-$PROJECT_DIR/sessions}" +case "$SESSIONS_DIR" in + /*) ;; + *) SESSIONS_DIR="$PROJECT_DIR/$SESSIONS_DIR" ;; +esac + +# A SessionStart hook is a read path. It must not create directories: a +# read-only checkout or a mistargeted PROJECT_DIR would turn a missing-context +# event into a nonzero exit and lose the injection entirely. The glob below +# handles a nonexistent directory correctly on its own. + +journal_status() { + # First word of the mandatory status line, upper-cased. Empty when absent. + sed -n 's/^[[:space:]]*\*\*Status:\*\*[[:space:]]*//p' "$1" 2>/dev/null \ + | head -n 1 \ + | awk '{print toupper($1)}' \ + | tr -cd '[:upper:]' +} + +# "Newest" is decided by modification time, with the filename as the tie-break. +# mtime alone is wrong whenever a filesystem hands several journals the same +# timestamp -- a fresh `git clone`, a branch checkout, an unpacked archive, a +# restored backup -- because bash 3.2 compares `-nt` at whole-second resolution +# and the tie silently keeps whichever name the glob yielded first, i.e. the +# OLDEST journal. The filename is the session-start time by specification and +# sorts lexicographically, so it settles ties correctly. mtime still leads, so a +# journal appended to across days stays live even though its name is older. +LATEST="" +LATEST_BASE="" +for candidate in "$SESSIONS_DIR"/*.md; do + [ -e "$candidate" ] || continue + base=$(basename "$candidate") + case "$base" in README.md|TEMPLATE.md) continue ;; esac + if [ -z "$LATEST" ]; then + LATEST=$candidate + LATEST_BASE=$base + elif [ "$candidate" -nt "$LATEST" ]; then + LATEST=$candidate + LATEST_BASE=$base + elif [ ! "$LATEST" -nt "$candidate" ] && [ "$base" \> "$LATEST_BASE" ]; then + LATEST=$candidate + LATEST_BASE=$base + fi +done + +# A DONE journal is a finished session, not the live frontier. Prefer the newest +# journal still open for work; fall back to reporting the closed one honestly +# rather than replaying its "no work remains" next step to a fresh agent. +LIVE="" +LIVE_BASE="" +if [ -n "$LATEST" ] && [ "$(journal_status "$LATEST")" = "DONE" ]; then + for candidate in "$SESSIONS_DIR"/*.md; do + [ -e "$candidate" ] || continue + base=$(basename "$candidate") + case "$base" in README.md|TEMPLATE.md) continue ;; esac + case "$(journal_status "$candidate")" in + ACTIVE|BLOCKED) ;; + *) continue ;; + esac + if [ -z "$LIVE" ]; then + LIVE=$candidate + LIVE_BASE=$base + elif [ "$candidate" -nt "$LIVE" ]; then + LIVE=$candidate + LIVE_BASE=$base + elif [ ! "$LIVE" -nt "$candidate" ] && [ "$base" \> "$LIVE_BASE" ]; then + LIVE=$candidate + LIVE_BASE=$base + fi + done +fi + +relative_path() { + local resolved="${1#"$PROJECT_DIR"/}" + [ "$resolved" = "$1" ] && resolved="$1" + printf '%s' "$resolved" +} + +if [ -z "$LATEST" ]; then + REL_SESSIONS=$(relative_path "$SESSIONS_DIR") + MESSAGE="No session journal exists in $REL_SESSIONS/. Once this session's intent is clear, create YYYY-MM-DD-HHMM-.md from $REL_SESSIONS/TEMPLATE.md and follow $REL_SESSIONS/README.md." +else + CLOSED="" + if [ "$(journal_status "$LATEST")" = "DONE" ]; then + if [ -n "$LIVE" ]; then + CLOSED=$(relative_path "$LATEST") + LATEST=$LIVE + else + CLOSED="only" + fi + fi + + RELATIVE=$(relative_path "$LATEST") + STATUS_LINE=$(grep -m 1 '^[[:space:]]*\*\*Status:\*\*' "$LATEST" 2>/dev/null || true) + [ -n "$STATUS_LINE" ] || STATUS_LINE="**Status:** Status not recorded" + + # Heading matching is case-insensitive: a journal written from memory rather + # than from the template routinely title-cases the heading, and an exact match + # silently drops the one section this hook exists to deliver. + NEXT_STEP=$(awk ' + tolower($0) ~ /^##[[:space:]]+next step for a fresh agent[[:space:]]*$/ { inside=1; next } + inside && /^## / { exit } + inside { print } + ' "$LATEST" 2>/dev/null | sed '/^[[:space:]]*$/d' || true) + if [ -z "$NEXT_STEP" ]; then + NEXT_STEP="Next step not recorded. This journal has no \"## Next step for a fresh agent\" section; read it in full, then add that section before ending the turn." + fi + + if [ "$CLOSED" = "only" ]; then + MESSAGE="Most recent session journal: $RELATIVE — its status is DONE, so it is a finished session and not the live frontier. Read it for background, treat this session as new work, and create a new journal once the intent is clear. +$STATUS_LINE +Last recorded next step (from the closed journal): +$NEXT_STEP" + elif [ -n "$CLOSED" ]; then + MESSAGE="Live session journal: $RELATIVE. Read it before acting. Continue the same work by appending to it; start unrelated work in a new journal. A more recent journal ($CLOSED) is already DONE and is background only. +$STATUS_LINE +Next step for a fresh agent: +$NEXT_STEP" + else + MESSAGE="Most recent session journal: $RELATIVE. Read it before acting. Continue the same work by appending to it; start unrelated work in a new journal. +$STATUS_LINE +Next step for a fresh agent: +$NEXT_STEP" + fi +fi + +json_string() { + if command -v python3 >/dev/null 2>&1; then + python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' + elif command -v python >/dev/null 2>&1; then + python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' + else + # Minimal documented fallback: preserve valid JSON by escaping the control + # characters a journal can realistically contain, then flattening newlines. + sed 's/\\/\\\\/g; s/"/\\"/g' \ + | tr '\t' ' ' \ + | tr -d '\r' \ + | tr '\n' ' ' \ + | sed 's/^/"/; s/$/"/' + fi +} + +ESCAPED=$(printf '%s' "$MESSAGE" | json_string) +printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "$ESCAPED" +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..5d84ce3 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.claude/hooks/session-journal-pointer.sh\"" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.claude/hooks/session-journal-nudge.sh\"" + } + ] + } + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d57a94c..5d55662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,12 +10,16 @@ permissions: jobs: test: strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: bats-core/bats-action@3.0.1 + # Actions are pinned by commit SHA. This repository already SHA-pins the + # gitleaks tarball below; a mutable tag on an action that can read the + # workflow token is the weaker link of the two. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: bats-core/bats-action@42fcc8700f773c075a16a90eb11674c0318ad507 # 3.0.1 - name: Install shellcheck on macOS if: runner.os == 'macOS' run: brew install shellcheck @@ -25,17 +29,54 @@ jobs: bash -n hooks/session-journal-nudge.sh bash -n extras/spine-promotion/journal-spine-promotion-nudge.sh bash -n install.sh + bash -n doctor.sh - name: Run shellcheck - run: shellcheck hooks/*.sh extras/spine-promotion/*.sh install.sh + run: shellcheck hooks/*.sh extras/spine-promotion/*.sh install.sh doctor.sh - name: Run bats suites run: bats tests - - name: Check tracked journals with the runtime secrets guard - run: printf '%s' '{"stop_hook_active":false}' | JOURNAL_FRESH_SECS=999999999 hooks/session-journal-nudge.sh + - name: Self-check this repository's own installation + run: ./doctor.sh . + - name: Check every tracked journal with the runtime secrets guard + # The hook deliberately scans only the newest journal, which is the right + # contract at runtime and the wrong one for a repository-wide gate. Older + # journals are history a reader still trusts, so CI walks all of them. + run: | + set -euo pipefail + shopt -s nullglob + status=0 + for journal in sessions/*.md; do + case "$(basename "$journal")" in README.md|TEMPLATE.md) continue ;; esac + scratch="$(mktemp -d)" + mkdir -p "$scratch/sessions" + cp "$journal" "$scratch/sessions/" + if ! printf '%s' '{"stop_hook_active":false}' \ + | CLAUDE_PROJECT_DIR="$scratch" SESSION_JOURNAL_DIR="$scratch/sessions" \ + JOURNAL_FRESH_SECS=999999999 SESSION_JOURNAL_SKIP=1 \ + hooks/session-journal-nudge.sh >/dev/null; then + echo "::error file=$journal::secret-shaped content in $journal" + status=1 + fi + rm -rf "$scratch" + done + exit "$status" + + bash32: + # The hooks advertise bash 3.2 compatibility -- the version macOS still + # ships -- and that was the one claim nothing tested. macos-latest runs + # /bin/bash 3.2, so exercise the suite against it explicitly. + runs-on: macos-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: bats-core/bats-action@42fcc8700f773c075a16a90eb11674c0318ad507 # 3.0.1 + - name: Report the interpreter under test + run: /bin/bash --version | head -n 1 + - name: Run the suite under /bin/bash 3.2 + run: /bin/bash -c 'bats tests' secrets: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Install gitleaks 8.24.3 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7469cc7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,117 @@ +# Changelog + +All notable changes to this project are recorded here. Versions follow +[semantic versioning](https://semver.org/): the public surface is the journal +shape, the hook contract, the installer flags, and the environment variables. + +## 1.1.0 + +The first release that treats the discipline as something people *operate* +rather than something people install. Every fix below is a defect that only +appears on the second of something: the second journal, the second session, the +second install, the second teammate. + +### Upgrade notes + +Re-run `./install.sh ` from a fresh clone. The rerun is now +non-destructive — it preserves your adapted `sessions/README.md`, your +instruction block, and any environment overrides on the generated commands, and +prints a line for each thing it kept. Use `--refresh-docs` to deliberately +regenerate them. + +After upgrading, run `./doctor.sh ` and commit the result of the install. + +Two behaviour changes are worth knowing about before you upgrade: + +- A journal whose `**Status:**` is `DONE` is no longer presented as the live + frontier. If you relied on a DONE journal's next step being injected, mark the + journal `ACTIVE` instead. +- Freshness now requires the journal's *content* to have changed when other + files changed, not just its timestamp. Set + `JOURNAL_REQUIRE_CONTENT_CHANGE=0` to restore the old mtime-only behaviour. + +### Fixed + +- **The newest journal is now actually the newest.** Selection was decided by + mtime alone, so any operation that gives several journals the same timestamp — + a fresh `git clone`, a branch checkout, an unpacked archive — tied the + comparison and kept the alphabetically *oldest* journal. A teammate cloning + the repository was handed the wrong journal. mtime still leads; the filename + now settles ties. +- **A finished session is no longer replayed as the live frontier.** The pointer + injected a `DONE` journal's next step verbatim, so a fresh agent could be told + "No repository work remains" as its entire first minute. The pointer now + prefers the newest `ACTIVE` or `BLOCKED` journal and, when every journal is + closed, says so. +- **The secrets guard no longer blocks its own prescribed remediation.** + `password: ` and `token: stored-in-ci-secrets` both exited 2, and + because the guard runs ahead of the `stop_hook_active` bail, the runtime could + not break the resulting block → redact → block loop. Values are now inspected + rather than merely detected. +- **Digest context matches whole words.** `Avoid`, `Android` and `paranoid` all + satisfied the `oid` exemption and silently disabled hex-secret detection for + neighbouring lines. +- **The Stop hook reads all of stdin.** Pretty-printed hook JSON used to defeat + the `stop_hook_active` check and suppress the marker fallback at the same + time, leaving no loop protection at all. +- **The one-shot marker expires.** It outlived the session that armed it and + silently swallowed the next session's first nudge. +- **A bare `touch` no longer satisfies the freshness gate** when other files in + the repository changed and the journal did not. +- **The installer refuses before it acts.** An unmatched sentinel used to + produce "refusing to modify it" *after* both hooks were copied and merged + live, leaving enforcement wired up with no instruction block, plus a stray + `.tmp` file in the repository. +- **Re-running the installer preserves your adaptations.** It previously deleted + the `JOURNAL_FRESH_SECS` override, the adapted sessions README, and any local + edit inside the instruction block — all three of which `ADOPT.md` instructs + the adopting agent to write. +- **The journal template is installed.** Both the pointer hook and the generated + README told agents to start from a local template that nothing ever placed, so + first journals were written from memory. This was the root cause of heading + drift. +- **Next-step headings match case-insensitively**, so a title-cased heading no + longer silently voids the injected payload. +- **Installing into a subdirectory of a git repository is refused** instead of + succeeding and then failing silently forever. +- **A journal directory that symlinks outside the repository is refused.** +- Neither hook creates the sessions directory any more; they are read paths. +- The optional spine-promotion hook no longer hands raw `awk` diagnostics to the + agent as a Stop block when the journal is unreadable. +- A non-numeric `JOURNAL_FRESH_SECS` warns and falls back to 300 instead of + leaking a shell error and blocking. + +### Added + +- `doctor.sh` — a re-runnable self-check. `ADOPT.md` Phase C was a matrix the + installing agent ran once and nobody could run again, yet every failure mode + here is silent. The doctor also checks what adoption never did: whether the + installation is committed, whether a teammate will actually receive it, + whether the registered command still resolves, and which journal the pointer + would deliver right now. +- `SESSION_JOURNAL_SKIP=1` — a documented escape for sessions that genuinely + need no journal. The secrets guard still runs. +- `JOURNAL_REQUIRE_CONTENT_CHANGE` and `JOURNAL_STOP_MARKER_TTL_SECS`. +- `--plan-pointer` so a deterministic install can produce THIN mode instead of + always contradicting `ADOPT.md`. +- `--refresh-docs` to deliberately regenerate what a rerun now preserves. +- A `VERSION` file, stamped into the installed hook scripts, so an adopted + repository can tell what it has. +- `SECURITY.md`. + +### Changed + +- The journal template asks for evidence a reader can re-run — a command, a + test, a CI URL — rather than a count frozen at write time. This project's own + verification record had drifted for exactly that reason. +- CI pins its actions by commit SHA, runs the suite under `bash 3.2` as well as + the system bash, runs the secrets guard over *every* tracked journal rather + than only the newest, and checks that the documentation's own claims still + match the code. + +## 1.0.0 + +Initial public release: the journal specification, THIN and DETAILED modes, the +`SessionStart` pointer, the `Stop` freshness nudge and secrets guard, the +`ADOPT.md` adoption contract, the deterministic installer, and the optional +spine-promotion module. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cf318f0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ + +## Session-journal discipline + +For substantive work, keep one journal in `sessions/` using `YYYY-MM-DD-HHMM-.md`. Read the newest journal before resuming work. Continue the same task in the same file; create a new file for unrelated work. + +Use THIN mode when `## Live plan pointer` names a governing artifact, and DETAILED mode when it is `none`. Before ending a resumable turn, update `**Last updated:**`, `**Status:**`, evidence, current state, and `## Next step for a fresh agent`. Never put secrets or credential fragments in a journal. Follow `sessions/README.md`. + +If both CLAUDE.md and AGENTS.md carry this block, keep their session-journal rules synchronized. + diff --git a/README.md b/README.md index a4fa7e5..15daa26 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ This pattern was hardened over months in private enterprise-delivery repositorie - A fixed journal shape optimized for a fresh agent's first minute. - THIN mode for work governed by a plan, and DETAILED mode for one-off work. -- `SessionStart` context containing the newest journal, status, and complete next step. +- `SessionStart` context containing the live journal, its status, and complete next step. - A one-shot `Stop` nudge for missing or stale journals. - A secrets guard that blocks common credential shapes without printing values. +- A re-runnable `doctor.sh` self-check, because a hook that stops firing is silent. - An agent-led adoption contract that surveys and adapts to the target repository. - A deterministic, idempotent installer and uninstall path. @@ -26,10 +27,9 @@ For offline use: ```bash npx degit dctmfoo/session-journal session-journal-source -# Or: git clone https://github.com/dctmfoo/session-journal.git ``` -Then point your agent at the local `ADOPT.md`. +Then point your agent at the local `ADOPT.md`. Pin a release tag rather than tracking `main` if you want a reproducible install. ## Deterministic install @@ -37,14 +37,41 @@ Run from a clone; the installer does not fetch code: ```bash ./install.sh /path/to/your-repo -./install.sh /path/to/your-repo --codex -./install.sh /path/to/your-repo --sessions-dir docs/sessions --codex ``` -Options are `--sessions-dir `, `--codex`, `--no-claude`, and `--uninstall`. `jq` is required because settings are merged structurally. Invalid JSON or JSON5 is refused rather than rewritten. +| Option | Effect | +| --- | --- | +| `--sessions-dir ` | Journal directory, relative to the target repo. Default `sessions`. | +| `--plan-pointer ` | Default `## Live plan pointer` for the generated docs. Default `none` (DETAILED). Give it a path to start in THIN mode. | +| `--codex` | Also install Codex hooks and the `AGENTS.md` block. | +| `--no-claude` | Skip the Claude Code hooks and the `CLAUDE.md` block. | +| `--refresh-docs` | Regenerate the sessions README, `TEMPLATE.md`, and instruction blocks, discarding local adaptations. | +| `--uninstall` | Remove the owned hooks, scripts, and instruction blocks. | + +`jq` is required because settings are merged structurally. Invalid JSON or JSON5 is refused rather than rewritten. Every refusal happens before the first byte is written, so a refused install never leaves hooks half-wired. + +Then commit the result. `.claude/` and the journal directory are what carry the discipline to your teammates; an install that only exists on your machine is not adopted. Codex project hooks load only for trusted projects. After installation, open `/hooks` in Codex and review the new definitions; changed definitions require review again. Codex CLI 0.144.1 was verified with both `SessionStart` and turn-scoped `Stop`, so the same pointer, freshness nudge, and secrets guard run in Codex. The AGENTS.md block keeps a manual fallback for older or disabled hook surfaces. +## Check the installation + +```bash +./doctor.sh /path/to/your-repo +``` + +Every failure mode in this project is silent by construction: a `SessionStart` hook that never fires looks exactly like a session with no journal, and a `Stop` hook that never fires looks exactly like a journal that is always fresh. The doctor re-runs the adoption verification matrix at any time, and checks what adoption cannot: whether the installation is committed, whether a teammate will actually receive it, whether the registered command still resolves, and which journal the pointer would deliver right now. It exits non-zero if anything fails, so it works in CI. + +## Updating + +Re-run the installer from a fresh clone: + +```bash +./install.sh /path/to/your-repo +``` + +The rerun preserves your adapted `sessions/README.md`, your `TEMPLATE.md`, your instruction block, and any environment overrides on the generated commands, printing a line for each thing it keeps. Pass `--refresh-docs` when you want the upstream wording back. Check [CHANGELOG.md](CHANGELOG.md) for upgrade notes, then run `./doctor.sh`. + ## The journal in one glance ```markdown @@ -59,11 +86,27 @@ Run the packaging check, then record the artifact hash here. The pointer makes this THIN mode: details stay in the release plan. With `none`, the journal becomes DETAILED and carries the working record itself. See [the full specification](spec/session-journal-spec.md) and [template](templates/session-journal.md). +Set `**Status:** DONE` when the work is finished. A DONE journal is background, not the live frontier — the next session starts clean instead of inheriting a completed next step. + +## Configuration + +Every setting is an environment variable on the generated hook command, so it lives in your settings file and travels with the repository. + +| Variable | Default | Effect | +| --- | --- | --- | +| `SESSION_JOURNAL_DIR` | `/sessions` | Journal directory. Set by `--sessions-dir`. | +| `JOURNAL_FRESH_SECS` | `300` | How old the newest journal may be before `Stop` nudges. | +| `SESSION_JOURNAL_SKIP` | unset | Set to `1` for a session that genuinely needs no journal — a typo fix, a dependency bump. The secrets guard still runs. | +| `JOURNAL_REQUIRE_CONTENT_CHANGE` | `1` | Require the journal's content to have changed when other files did, not just its timestamp. Set to `0` for mtime-only freshness. | +| `JOURNAL_STOP_MARKER_TTL_SECS` | `60` | How long the one-shot loop marker stays valid, for runners that supply no stdin. | + ## Safety model -Journals are committed by default, so secret handling is deliberately strict. The Stop hook scans only the newest journal for JWT-like values, bearer values, credential assignments, common provider-token shapes, private-key headers, and long hexadecimal strings. It reports line numbers, never matched values. Named credential shapes are always flagged. A bare hexadecimal string is flagged only away from digest context: if the line, the line above, or the line below mentions a digest, hash, checksum, fingerprint, commit, revision, blob, oid, or etag, the value is treated as verifiable evidence and left alone. A remaining false positive can carry ``, and that escape applies only to its own line. +Journals are committed by default, so secret handling is deliberately strict. The Stop hook scans only the newest journal for JWT-like values, bearer values, credential assignments, provider-token shapes, private-key headers, and long hexadecimal strings. It reports line numbers, never matched values. + +Self-identifying shapes are always flagged. For a `key: value` assignment the *value* is inspected, not just the key name, so the redactions this guard asks for — ``, `$STRIPE_API_KEY`, `redacted (32 chars)`, `stored-in-ci-secrets` — are accepted rather than blocking the agent in a redact-and-reblock loop. A bare hexadecimal string is flagged only away from digest context: if the line, the line above, or the line below mentions a digest, hash, checksum, fingerprint, commit, revision, blob, oid, or etag *as a whole word*, the value is treated as verifiable evidence and left alone. A remaining false positive can carry ``, and that escape applies only to its own line. -This is a continuity aid, not a credential scanner for the whole repository. Keep your normal secret-scanning and review gates. +This is a continuity aid, not a credential scanner for the whole repository. Keep your normal secret-scanning and review gates. See [SECURITY.md](SECURITY.md). ## Optional spine promotion @@ -75,7 +118,25 @@ Long-running DETAILED journals often mean a durable plan is missing. The optiona ./install.sh /path/to/your-repo --codex --uninstall ``` -The installer removes only its hook groups, scripts, and sentinel-delimited instruction blocks. It keeps journals because they are project history. +The installer removes only its hook groups, scripts, and sentinel-delimited instruction blocks. It keeps journals, their README, and `TEMPLATE.md` because they are project history. + +## Troubleshooting + +Indexed by what you actually observe. + +**The agent starts a session and says nothing about a journal.** The `SessionStart` hook is not firing. Run `./doctor.sh` — the likely causes are an uncommitted or gitignored `.claude/`, an install into a subdirectory rather than the repository root, or a hook script that lost its executable bit. + +**The agent was handed an old journal.** Fixed in 1.1.0: selection used to be decided by mtime alone, and a fresh clone gives every journal the same timestamp, which kept the alphabetically oldest one. Upgrade, then confirm with `./doctor.sh`, which reports which journal the pointer would deliver. + +**A fresh agent is told there is no work left.** The newest journal is `DONE`. Since 1.1.0 the pointer prefers the newest `ACTIVE` or `BLOCKED` journal and, when every journal is closed, says so rather than replaying a finished next step. + +**`Stop` keeps blocking no matter what the agent writes.** The secrets guard is matching something. It runs ahead of the loop protection by design, so `stop_hook_active` will not clear it. The message names the line numbers; redact the value to a key name, an env-var reference, or ``. If the line is a verified false positive, append `` to it. + +**`Stop` nudges even though the journal was just updated.** Since 1.1.0 the gate requires content to change, not just the timestamp, when other files in the repository changed. If your workflow legitimately touches without editing, set `JOURNAL_REQUIRE_CONTENT_CHANGE=0`. + +**Every trivial session demands a journal.** Set `SESSION_JOURNAL_SKIP=1` for that session. + +**A teammate cloned the repo and none of this works for them.** `.claude/` is probably gitignored or was never committed. `./doctor.sh` reports this explicitly. ## FAQ @@ -83,9 +144,11 @@ The installer removes only its hook groups, scripts, and sentinel-delimited inst **One journal per agent?** No. One per substantive human working session. A monorepo usually keeps one journal directory at its workspace root. -**Does Stop block forever?** No. Hook JSON uses `stop_hook_active`; empty-stdin runners get a one-shot marker fallback. The secrets guard still runs after the freshness nudge has been spent. +**Does Stop block forever?** No. Hook JSON uses `stop_hook_active`; empty-stdin runners get a short-lived marker fallback. The secrets guard still runs after the freshness nudge has been spent, and it is the one thing that can block repeatedly — deliberately, because the alternative is committing a credential. + +**Can journals be local-only?** Yes. The default is committed continuity, but ADOPT.md explicitly asks when ignore intent is ambiguous. `./doctor.sh` will tell you which you have. -**Can journals be local-only?** Yes. The default is committed continuity, but ADOPT.md explicitly asks when ignore intent is ambiguous. +**Why is there no database?** Because the journal is the compression. It is written by an agent, read by a human, reviewed in a pull request, and carried by git. A store would add a runtime, a migration story, and a corruption story to a project whose entire value is that it has none of those. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..32b47fe --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,63 @@ +# Security policy + +## What this project does to your repository + +`install.sh` copies two executable shell scripts into `.claude/hooks/` and wires +them into `SessionStart` and `Stop` in your agent settings. From then on, your +agent runtime executes them automatically, on every session start and every +turn end, with your permissions. Read both scripts before you install them — +they are short and deliberately dependency-free. + +The installer never fetches code. It only copies from the clone you run it +from, which is why the README tells you to clone or `degit` first. + +## What the secrets guard is, and is not + +The `Stop` hook scans **only the newest journal** for common credential shapes: +JWT-like values, bearer values, credential assignments, provider token prefixes, +private-key headers, and long hexadecimal strings outside digest context. It +reports line numbers and never echoes a matched value, so the hook itself cannot +leak a secret into a transcript. + +It is a continuity aid, not a credential scanner: + +- It scans one file, not your repository. +- It is a shape matcher. It will miss a secret that does not look like one — a + passphrase in prose, a credential split across lines, an encoded blob. +- It fails open on anything it cannot parse, because a Stop hook that blocks + forever is worse than one that misses. +- The `` escape suppresses a line entirely. It exists + for verified false positives and should be added by a human, not an agent. + +Keep your normal secret scanning and review gates. This project runs +[gitleaks](https://github.com/gitleaks/gitleaks) over its own full history in +CI and recommends you do the same. + +## Journals are committed by default + +That is deliberate — continuity that git does not carry does not reach your +teammates. It also means anything an agent writes into a journal is destined for +your history. The content contract in the generated `sessions/README.md` exists +for that reason, and `./doctor.sh` will tell you whether your journals are +tracked or local-only. + +## Reporting a vulnerability + +Open a [security advisory](https://github.com/dctmfoo/session-journal/security/advisories/new) +rather than a public issue. Please include the version from your installed hook +header (`session-journal `), your agent runtime and version, and a +reproduction. + +Report privately if you find: + +- a way to make either hook execute attacker-controlled content from a journal, +- a path in the installer that writes outside the target repository, +- a way to make the secrets guard echo a matched value, +- a way for a crafted journal to hang or infinitely block a Stop hook. + +A missed secret shape is a bug, not a vulnerability — open a normal issue with a +synthetic example. Never put a real credential in an issue. + +## Supported versions + +The latest release is supported. See [CHANGELOG.md](CHANGELOG.md). diff --git a/docs/verification.md b/docs/verification.md index f50e613..7df663f 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,6 +1,30 @@ # Verification record -Verified 2026-07-13 on macOS. This document records observed behavior, not just intended configuration. +This document records observed behavior, not just intended configuration. It has +two parts: a **dated adoption record** that is a snapshot and is never rewritten, +and a **current gates** section that must stay true today. Counts inside the +dated record describe the revision they were taken at; counts in the current +section are checked by `tests/docs.bats`, so they cannot quietly rot. + +## Current gates + +Run these to reproduce the claim rather than trusting a number frozen here: + +```bash +bash -n hooks/*.sh install.sh doctor.sh extras/spine-promotion/*.sh +shellcheck hooks/*.sh install.sh doctor.sh extras/spine-promotion/*.sh +bats tests +./doctor.sh . +``` + +| Gate | Claim | +| --- | --- | +| bats-core | 95 checks, all passing | +| Shellcheck and `bash -n` | clean for every shell entrypoint | +| Self-check | `./doctor.sh .` passes against this repository's own installation | +| Secrets | the runtime guard runs over every tracked journal in CI, plus a pinned full-history gitleaks scan | + +## Adoption record — 2026-07-13, macOS ## Automated suites @@ -8,12 +32,12 @@ Verified 2026-07-13 on macOS. This document records observed behavior, not just | --- | --- | | Shell syntax | `bash -n` passed for the installer, both core hooks, and the optional spine-promotion hook | | JSON syntax | Both hook snippets parsed with `jq` | -| bats-core 1.13.0 | 38/38 checks passed | +| bats-core 1.13.0 | 38/38 checks passed at that revision | | Installer lifecycle | Fresh install, merge preservation, idempotent rerun, portable custom journal path, Codex-only wiring, invalid-input refusal, and repeatable uninstall passed | | Secrets behavior | Credential and provider pattern classes, safe negatives, one-line allowlist, and guard-under-loop-protection passed | | Optional spine promotion | Empty, THIN, below-threshold, threshold, and configured-override cases passed | -The first proof-first run intentionally had 25 failures because the hooks and installer did not exist yet. The suite grew to 38 checks during review; all pass after implementation and hardening. +The first proof-first run intentionally had 25 failures because the hooks and installer did not exist yet. The suite grew to 38 checks during review; all passed after implementation and hardening. It has grown since: see **Current gates** above for the live count. ## Live Claude Code @@ -75,7 +99,7 @@ Pre-publication result after the final review fix: - Customer and personal-trace grep: zero matches. - Manual review: all 28 tracked files read; examples are synthetic and no private source file was copied. - Shellcheck and `bash -n`: clean for every shell entrypoint. -- bats-core: 38/38 passing, including review regressions for path traversal, portable relocation, malformed JSON, sentinel integrity, repeatable uninstall, and spine promotion. +- bats-core: 38/38 passing at that revision, including review regressions for path traversal, portable relocation, malformed JSON, sentinel integrity, repeatable uninstall, and spine promotion. - Relocation fixture: a custom `docs/sessions/` install still resolved and returned valid pointer JSON after the target repo moved to a different absolute path containing spaces. - Structured code review: installer command injection/path escape and portability findings fixed; CI now repeats shellcheck, tracked-journal guarding, bats, and full-history gitleaks. diff --git a/sessions/2026-08-27-0130-operate-the-discipline.md b/sessions/2026-08-27-0130-operate-the-discipline.md new file mode 100644 index 0000000..9daa5db --- /dev/null +++ b/sessions/2026-08-27-0130-operate-the-discipline.md @@ -0,0 +1,88 @@ +# Session: fix the defects that only appear when the discipline is operated, not installed + +**Started:** 2026-08-27 01:30 IST +**Last updated:** 2026-08-27 02:54 IST +**Status:** ACTIVE — hooks, installer, doctor, docs, and CI are done; adversarial re-review of the changes is pending + +## User intent + +Compare this project against `stephenschoettler/hermes-lcm` — a ~64k-line Python +"Lossless Context Management" plugin solving the same problem at the opposite +weight — look for ways to improve session-journal, then "fix everything". + +## Live plan pointer +none + +## Milestones + +- 2026-08-27 01:35 — Cloned hermes-lcm and surveyed both repos. Ground truth: hermes-lcm is 63,730 lines of Python over 242 commits; session-journal is 924 lines of shell and markdown over 10 commits. +- 2026-08-27 01:52 — Reproduced the headline defect before delegating: in a fresh `git clone`, the pointer hook injected the alphabetically *oldest* journal, because every file lands in the same second and bash 3.2 compares `-nt` at whole-second resolution. +- 2026-08-27 02:12 — Six-agent exploration finished (four lenses, synthesis, adversarial review). Its most valuable output was a refutation: the obvious "sort by filename" fix would have broken journals appended to across days, which the spec explicitly allows. +- 2026-08-27 02:20 — Twelve defects confirmed by direct reproduction, not by agent assertion. +- 2026-08-27 02:35 — Hooks fixed: selection, DONE handling, heading tolerance, secrets guard, stdin drain, marker TTL, content-change freshness. Suite 41 → 70. +- 2026-08-27 02:45 — Installer made safe to run twice and safe to fail; `doctor.sh` added. Suite 70 → 89. +- 2026-08-27 02:54 — Docs, spec 1.1, CHANGELOG, SECURITY.md, and CI hardening. Suite 89 → 95. + +## Decisions and constraints + +- **Borrow ideas from hermes-lcm, never mechanisms.** Its database, summary DAG, embeddings, retrieval tools, and rollups all answer problems this project deliberately does not have: there is no transcript to store, because the journal *is* the compression. Four ideas transferred at markdown-and-shell weight: occurrence time is not observation time, current is not last-finalized, redaction must be idempotent by construction, and prose belongs under test. +- **Selection is mtime-first with filename as tie-break, not filename-first.** `spec:36` allows appending to one journal across days, so an older filename can legitimately be the live journal. Filename-first would have regressed that while fixing the clone case. +- **The secrets guard inspects values, not just key names.** It was blocking `password: ` — the exact remediation its own message prescribes — ahead of the `stop_hook_active` bail, so the runtime could not break the loop. Detection is deliberately slightly weaker than a redact-and-reblock trap. +- **Freshness must measure work, not timestamps.** A bare `touch` satisfied the old gate with zero characters changed. Where git can tell, the journal must have actually moved. `JOURNAL_REQUIRE_CONTENT_CHANGE=0` restores the old behaviour for workflows this annoys. +- **No Claude-only plugin distribution.** It would install per-machine outside git, breaking both Codex parity and the git-reviewable, team-shared premise. + +## Verification evidence + +Prefer re-running these over trusting the numbers: + +- `bats tests` — 95 checks, currently all passing. `tests/docs.bats` fails deliberately if this count drifts from `grep -c '^@test' tests/*.bats`. +- `shellcheck hooks/*.sh extras/spine-promotion/*.sh install.sh doctor.sh` — clean. +- `./doctor.sh .` — passes against this repository's own installation. +- Fresh-clone selection: clone a repo with journals dated January, May and August; the pointer must name the August journal. Was naming January. +- DONE handling: `./hooks/session-journal-pointer.sh` in this repo used to emit "No repository work remains" as a fresh agent's whole first minute. +- Redaction acceptance: `password: `, `token: stored-in-ci-secrets`, `api_key: $STRIPE_API_KEY` must all exit 0; `api_key: sk-live-...` must still exit 2. +- Digest context: a line containing "Avoid" or "Android" must no longer suppress hex detection on its neighbours, while a real `SHA-256:` receipt still survives. +- Installer refusal: an unmatched sentinel must leave no `.claude/`, no `sessions/README.md`, and no `.tmp` file. + +## Commits made + +- `0deb916` fix: operate on the journal the agent actually needs, and stop the Stop-hook loops +- `34d2e60` fix: make installation safe to run twice and safe to fail, and add a doctor +- (pending) docs, spec 1.1, CI hardening, and this journal + +## Files touched + +- `hooks/session-journal-pointer.sh` — selection rule, DONE handling, case-insensitive headings, no mkdir on a read path +- `hooks/session-journal-nudge.sh` — value-aware secrets guard, whole-word digest context, full stdin drain, marker TTL, content-change freshness, `SESSION_JOURNAL_SKIP` +- `extras/spine-promotion/journal-spine-promotion-nudge.sh` — same selection rule; fails open on an unreadable journal +- `install.sh` — preflight before any write, non-destructive rerun, ships `TEMPLATE.md`, `--plan-pointer`, `--refresh-docs`, version stamping +- `doctor.sh` — new; re-runnable self-check +- `spec/session-journal-spec.md` — 1.1: selection is now specified, not left to the implementation +- `templates/`, `README.md`, `CHANGELOG.md`, `SECURITY.md`, `docs/verification.md`, `.github/workflows/ci.yml`, `tests/` + +## Where we are now + +All twelve confirmed defects are fixed, each with a regression test. The project +now dogfoods itself: `.claude/` is installed here, so CI's `./doctor.sh .` step +is a real check rather than a formality. The verification record separates a +dated adoption snapshot from live gates that `tests/docs.bats` holds to the code. + +## Next step for a fresh agent + +Run `bats tests` and `./doctor.sh .` to confirm the tree is green, then run an +adversarial review of the diff on `fix/operate-the-discipline` against `main` — +in particular the awk placeholder vocabulary in `hooks/session-journal-nudge.sh`, +which trades some detection strength for escaping a redact-and-reblock loop, and +the `journal_records_work` git heuristic, which must fail open in every case it +cannot prove. + +## Open questions / loose ends + +- The freshness content-check nudges when the work tree is dirty and the journal is not. A repository with long-lived uncommitted files will see this more often than intended; `JOURNAL_REQUIRE_CONTENT_CHANGE=0` is the escape, but the heuristic may want a quieter default. +- Commit `0016aa6` changed hook behaviour in this repository with no journal written, which means the Stop hook was either bypassed or not firing here. Installing the hooks into this repo should settle it; the answer is worth knowing. +- `1.1.0` is not tagged yet, and the README still tells strangers to `degit` a moving `main`. + +## Change log + +- 2026-08-27 01:30 — journal created +- 2026-08-27 02:54 — recorded hook, installer, doctor, docs, and CI work diff --git a/sessions/TEMPLATE.md b/sessions/TEMPLATE.md new file mode 100644 index 0000000..2b6f9cb --- /dev/null +++ b/sessions/TEMPLATE.md @@ -0,0 +1,44 @@ +# Session: + +**Started:** YYYY-MM-DD HH:MM IST +**Last updated:** YYYY-MM-DD HH:MM IST +**Status:** ACTIVE | DONE | BLOCKED — one current-state line + +## User intent + + +## Live plan pointer +none + +## Milestones +- YYYY-MM-DD HH:MM — + +## Decisions and constraints +- + +## Verification evidence +" stays true; "38 of 38 passed" silently rots the moment the suite grows, +and the next agent will believe it.> +- — + +## Commits made +- , or `none` + +## Files touched +- `path` — + +## Where we are now + + +## Next step for a fresh agent + + +## Open questions / loose ends +- + +## Change log +- YYYY-MM-DD HH:MM — journal created diff --git a/spec/session-journal-spec.md b/spec/session-journal-spec.md index f4d511e..3eb4770 100644 --- a/spec/session-journal-spec.md +++ b/spec/session-journal-spec.md @@ -1,6 +1,6 @@ # Session Journal Specification -Version 1.0 +Version 1.1 ## Purpose @@ -20,7 +20,9 @@ The header contains bold labels, not YAML: The body contains, in order: User intent; Live plan pointer; Milestones; Decisions and constraints; Verification evidence; Commits made; Files touched; Where we are now; Next step for a fresh agent; Open questions / loose ends; Change log. -`Next step for a fresh agent` is mandatory and concrete. Name the next command, file, test, decision, or owner action. +`Next step for a fresh agent` is mandatory and concrete. Name the next command, file, test, decision, or owner action. It is injected verbatim at the start of the next session, so write it for a reader who has seen nothing else. Section headings are matched case-insensitively, but the template's capitalization is the canonical form. + +Journal directories also carry two non-journal files, `README.md` and `TEMPLATE.md`. Neither is a journal and neither is ever selected as one. ## Modes @@ -37,8 +39,27 @@ Create a journal once intent is clear. Continue the same work in the same file e Update `Last updated`, the one-line status, evidence, current state, next step, and change log before the end of every resumable turn. A Stop hook may treat a journal older than five minutes as stale; repositories can configure that window. +Not every session earns a journal. A typo fix, a dependency bump, or a question answered without changing anything should be skipped explicitly rather than recorded as a journal about nothing. + +## Selecting the live journal + +The live journal is the one a fresh agent must read first. Selecting it is a defined operation, not an implementation detail: + +1. Consider every `*.md` in the journal directory except `README.md` and `TEMPLATE.md`. +2. Prefer the most recently modified. Modification time reflects the working session, which is why a journal appended to across days stays live even though its filename is older. +3. Break ties by filename, descending. Ties are common and not exotic: a fresh clone, a branch checkout, an unpacked archive, or a restored backup gives every journal the same timestamp, and the filename carries the session-start time precisely so it can settle them. +4. `DONE` marks a finished session, not the live frontier. If the journal selected above is `DONE`, prefer instead the most recent journal whose status is `ACTIVE` or `BLOCKED`. If none exists, report that the newest journal is closed rather than presenting its next step as live work. + +## Evidence + +Record evidence a reader can re-run: the command, the test name, the CI run, the artifact reference. A count captured at write time — "38 of 38 passing" — becomes false as soon as the suite grows, and the next agent will believe it and repeat it. A journal is the highest-trust artifact a fresh agent reads, so a wrong line in it costs more than a wrong line elsewhere. + ## Safety and signal Include only concise outcomes, decisions, verification evidence, commit references, issue IDs, and artifact pointers. Never include secrets, credential fragments, private tokens, large logs, duplicated plan prose, or unverified claims. -The newest journal is the hook contract. `SessionStart` injects its relative path, status, and complete next-step section. `Stop` checks freshness, mode-aware update guidance, one-shot loop protection, and secret-shaped content. +Redact a credential to a key name, an env-var reference, or an explicit placeholder. A redaction must not itself read as a credential; conforming redactions are expected to pass the guard. + +The live journal is the hook contract. `SessionStart` injects its relative path, status, and complete next-step section. `Stop` checks freshness, mode-aware update guidance, one-shot loop protection, and secret-shaped content. Both are read paths with respect to the journal directory: neither creates it. + +Freshness means the journal moved with the work. A timestamp that advances while the journal's content does not, in a session where other files changed, is not freshness. diff --git a/tests/docs.bats b/tests/docs.bats new file mode 100644 index 0000000..e89c24b --- /dev/null +++ b/tests/docs.bats @@ -0,0 +1,51 @@ +#!/usr/bin/env bats + +# Documentation drift is a product problem here, not a cosmetic one: a journal +# is the highest-trust artifact a fresh agent reads, and this repository has +# already shipped a wrong test count that propagated from a journal into +# docs/verification.md because the next agent believed it. These checks hold the +# prose to the code. + +repo() { printf '%s' "$BATS_TEST_DIRNAME/.."; } + +@test "the verification record's bats count matches the suite" { + claimed="$(sed -n 's|.*\([0-9]*\).*|\1|p' "$(repo)/docs/verification.md")" + [ -n "$claimed" ] + actual="$(grep -ch '^@test' "$(repo)"/tests/*.bats | awk '{n+=$1} END{print n}')" + [ "$claimed" -eq "$actual" ] +} + +@test "the changelog documents the current VERSION" { + version="$(cat "$(repo)/VERSION")" + [ -n "$version" ] + grep -Fq "## $version" "$(repo)/CHANGELOG.md" +} + +@test "every environment variable the hooks read is documented" { + for name in JOURNAL_FRESH_SECS SESSION_JOURNAL_DIR SESSION_JOURNAL_SKIP \ + JOURNAL_REQUIRE_CONTENT_CHANGE JOURNAL_STOP_MARKER_TTL_SECS; do + grep -Fq "$name" "$(repo)/README.md" || { + echo "$name is read by a hook but never documented in README.md" + return 1 + } + done +} + +@test "every installer flag is documented in the README" { + for flag in --sessions-dir --plan-pointer --codex --no-claude --refresh-docs --uninstall; do + grep -Fq -- "$flag" "$(repo)/README.md" || { + echo "$flag is accepted by install.sh but never documented in README.md" + return 1 + } + done +} + +@test "the shipped template carries the mandatory next-step section" { + grep -Fq '## Next step for a fresh agent' "$(repo)/templates/session-journal.md" + grep -Fq '## Live plan pointer' "$(repo)/templates/session-journal.md" +} + +@test "the spec and the pointer hook agree that DONE is not the live frontier" { + grep -Fq 'DONE' "$(repo)/spec/session-journal-spec.md" + grep -Fq 'ACTIVE|BLOCKED' "$(repo)/hooks/session-journal-pointer.sh" +} From 5fc948c065b2d2cfb7c5c93e8aa346772f5a734b Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Thu, 27 Aug 2026 02:58:31 +0530 Subject: [PATCH 04/14] docs: teach ADOPT.md the doctor, the template, and the handoff to a team The contract ended at 'installed for you' and never reached 'your team is using this'. Nothing told the adopting agent to commit .claude/, nothing checked whether it was gitignored, and nothing mentioned that a teammate's first clone is the entire distribution mechanism. Phase D covers that now. Phase C was a matrix only the installing agent could ever run. It now leads with ./doctor.sh and keeps only the behavioural cases the doctor cannot construct -- including two new ones the old matrix could not have caught: a DONE journal must not be replayed as live work, and the redaction the secrets guard prescribes must actually clear the block. Also documents --plan-pointer, the shipped TEMPLATE.md, the non-destructive re-run as the upgrade path, and SESSION_JOURNAL_SKIP -- without a documented escape for trivial sessions, a team learns to route around the Stop hook, and the discipline dies quietly. --- ADOPT.md | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/ADOPT.md b/ADOPT.md index bc740bf..0851b17 100644 --- a/ADOPT.md +++ b/ADOPT.md @@ -18,44 +18,59 @@ Inspect and record for your final report: 4. Journal location: default to root `sessions/`; prefer `docs/sessions/` when the repository consistently keeps process docs under `docs/` or tightly controls published root files. A monorepo normally gets one workspace-root journal directory because a journal follows the human session, not a package. 5. Timezone: run `date +%Z` in the target repository and use that machine-local zone label. Do not use `date -u`, `UTC` by convention, or a zone inherited from this upstream repository. 6. Repository vocabulary: add branch lines for multi-repository work and name relevant issue, gate, ADR, or test identifiers in the local evidence guidance. -7. Ignore rules: journals are intended to be committed. Warn if an existing rule ignores the chosen directory, then ask Question 2 only if intent remains unclear. +7. Ignore rules: journals are intended to be committed. Warn if an existing rule ignores the chosen directory, then ask Question 2 only if intent remains unclear. Check `.claude/` too — a repository that ignores it will silently give the hooks to nobody but the installing machine. +8. Repository root: the installer refuses to install into a subdirectory of a git repository, because the generated hook commands resolve their own root at runtime. Confirm you are targeting the root. Before writing, ask Question 3 unless the user's request already says whether to install the optional spine-promotion module. Do not infer opt-in or opt-out from repository shape. ## Phase B — install and adapt -1. Prefer the tested installer instead of rebuilding its merge logic. Clone or download this repository to a temporary location, then run `./install.sh ` with the discovered `--sessions-dir`, `--codex`, and/or `--no-claude` flags. If the installer cannot run, perform the equivalent steps below manually and explain why in the final report. -2. The installer puts one real copy of both scripts in `.claude/hooks/`, merges `SessionStart` and `Stop` without replacing existing groups, and uses runtime-relative commands so subdirectory starts and relocated clones work. Project-local Codex hooks require a trusted project and hook review through `/hooks`. -3. Adapt the generated sessions README with the strongest THIN default, the `date +%Z` result, and repository-specific evidence vocabulary. Keep the generated directory accurate. -4. Adapt the sentinel-delimited blocks in CLAUDE.md and AGENTS.md. Keep the twin-sync sentence only when both instruction files exist. Instruction-only runtimes need the manual end-of-turn check. -5. Set `JOURNAL_FRESH_SECS` in the generated hook commands only when the repository needs a non-default freshness window. The core installer does not manage this optional override. -6. If Question 3 opted into spine promotion, merge `extras/spine-promotion/journal-spine-promotion-nudge.sh` as an additional Stop handler and test it. This optional handler is manually managed and must also be removed manually; the core installer intentionally owns only its pointer and freshness/secrets hooks. +1. Prefer the tested installer instead of rebuilding its merge logic. Clone or download this repository to a temporary location, then run `./install.sh ` with the discovered `--sessions-dir`, `--plan-pointer`, `--codex`, and/or `--no-claude` flags. If the installer cannot run, perform the equivalent steps below manually and explain why in the final report. +2. The installer puts one real copy of both scripts in `.claude/hooks/`, merges `SessionStart` and `Stop` without replacing existing groups, and uses runtime-relative commands so subdirectory starts and relocated clones work. It also writes `/README.md` and `/TEMPLATE.md`. Project-local Codex hooks require a trusted project and hook review through `/hooks`. +3. Pass `--plan-pointer ` when Phase A found a strong governing artifact, so the generated docs start in THIN mode rather than contradicting your own survey. +4. Adapt the generated sessions README with the strongest THIN default, the `date +%Z` result, and repository-specific evidence vocabulary. Adapt `TEMPLATE.md` if the repository needs extra sections. A later re-run of the installer preserves both; it will say so, and only `--refresh-docs` overwrites them. +5. Adapt the sentinel-delimited blocks in CLAUDE.md and AGENTS.md. Keep the twin-sync sentence only when both instruction files exist. Instruction-only runtimes need the manual end-of-turn check. A re-run preserves your edits inside the sentinels. +6. Set `JOURNAL_FRESH_SECS` in the generated hook commands only when the repository needs a non-default freshness window. A re-run preserves environment overrides you add here. +7. If Question 3 opted into spine promotion, merge `extras/spine-promotion/journal-spine-promotion-nudge.sh` as an additional Stop handler and test it. This optional handler is manually managed and must also be removed manually; the core installer intentionally owns only its pointer and freshness/secrets hooks. Never delete or rewrite an existing user hook. Never commit unless the user asked or repository conventions already authorize it. Never put a real or realistic credential in a demonstration journal. If a settings file contains comments or JSON5, do not normalize or overwrite it: show the matching snippet and ask the user to merge it manually. ## Phase C — verify before finishing -Run and report this matrix: +Run `./doctor.sh ` and include its output in your report. It must exit 0 except for the "installation is committed" check, which Phase D resolves. It covers installation integrity, command resolution, journal-directory contents, git reachability, and a live pointer and Stop run. + +Then run the behavioral cases the doctor cannot construct for you: | Check | Expected proof | | --- | --- | -| Static scripts | `bash -n` succeeds; both scripts are executable | -| Settings | `jq .` succeeds for each modified JSON file; pre-existing hooks remain | -| Empty pointer | Direct SessionStart run returns valid JSON with a create instruction | +| Empty pointer | With no journal present, SessionStart returns valid JSON with a create instruction naming `TEMPLATE.md` | | Populated pointer | A synthetic journal yields its relative path, Status, and complete next step | +| DONE handling | A synthetic `**Status:** DONE` journal is reported as finished, not replayed as the live next step | | Fresh Stop | Fresh journal plus `{"stop_hook_active":false}` exits 0 | | Stale Stop | Old THIN and DETAILED fixtures exit 2 with mode-correct guidance | | Loop protection | `{"stop_hook_active":true}` skips freshness and exits 0 | | Secrets guard | Construct a synthetic credential assignment at runtime; Stop exits 2 without echoing the value | +| Redaction accepted | Replacing that value with `` exits 0, so the prescribed fix actually clears the block | Delete verification fixtures. Then write the first real journal about this adoption session, with no secrets, and run the pointer once more so the install demonstrates itself. For Codex, trust the project, review the changed hooks with `/hooks`, and verify behavior with the installed Codex version. For Claude Code, run a small headless task if available. If a runtime cannot be exercised, say so and retain the manual fallback wording; never claim an unobserved hook fired. -## Phase D — document reversal +## Phase D — hand it to the team + +An installation that exists only on one machine is not adopted. Unless the user declined committing: + +1. Commit `.claude/` (or `.codex/`), the instruction-file blocks, and the journal directory together, so a teammate's first clone arrives with the discipline already wired. +2. Confirm no ignore rule excludes them. `./doctor.sh` fails explicitly when the hook settings are gitignored or uncommitted. +3. Tell the user what a teammate must do on first clone — normally nothing, which is the point — and that `./doctor.sh` is how anyone checks later, since a hook that stops firing is otherwise silent. +4. Mention `SESSION_JOURNAL_SKIP=1` for sessions that genuinely need no journal. Without it, the team learns to route around the Stop hook, and the discipline dies quietly. + +## Phase E — document reversal + +Uninstalling removes the two owned hook groups, the two installed scripts, and the sentinel-delimited instruction blocks. It keeps the journal directory, its README, and TEMPLATE.md by default because they are project history. The deterministic installer performs this with `./install.sh --uninstall` using the same runtime flags used for installation. -Uninstalling removes the two owned hook groups, the two installed scripts, and the sentinel-delimited instruction blocks. Keep the journal directory by default because it is project history. The deterministic installer performs this with `./install.sh --uninstall` using the same runtime flags used for installation. +Upgrading is the same command as installing: re-run `./install.sh ` from a fresh clone. The re-run preserves the adapted README, TEMPLATE, instruction blocks, and environment overrides, and reports each thing it kept. ## Final report -Report the survey decisions, changed files, exact verification matrix, any runtime limitation, first-journal path, and uninstall command. Mention any question that used the three-question budget. +Report the survey decisions, changed files, the `./doctor.sh` output, the behavioral matrix, any runtime limitation, first-journal path, whether the install was committed, and the uninstall command. Mention any question that used the three-question budget. From a9a5e9b5571efc042c3bb608be2e4c72516420ac Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Thu, 27 Aug 2026 03:33:28 +0530 Subject: [PATCH 05/14] fix: repair the regressions an adversarial review found in the 1.1.0 fixes A four-lens review of this branch found that two of the fixes had broken what they were meant to protect. Both were the changes flagged as riskiest when the branch was written, and both were wrong in the opposite direction to the fear. Secrets guard: the rewrite made detection materially weaker while every test stayed green, because the suite only ever exercised bare, unquoted values. `PASSWORD="S3cr3tP4ssw0rdXyz"`, a backticked value, a markdown-emphasised value, and `P@ssw0rd!Secret1` all went from blocked to allowed. Two rules were responsible: quotes and backticks were read as evidence of a placeholder, and a credential-alphabet whitelist rejected any value containing punctuation -- which is backwards, since punctuation makes a value more password-like, not less. Decoration is now stripped before the check and the whitelist is gone, so only a placeholder shape can exempt a value. The matcher also stopped at the first credential per line, so a line that redacted its first value and forgot its second was exempted whole; every key on the line is examined now. An ellipsis is recognised as an elided example, which is how documentation refers to a secret without containing one. Freshness gate: it equated "the journal changed" with "the journal is dirty in the working tree", so committing the journal -- the workflow this project teaches -- turned the gate hostile. Any remaining dirty path, down to an untracked .DS_Store, produced a Stop block asserting the journal's content had not changed seconds after it changed and was committed. Untracked files are now ignored, a journal carried by the most recent commit counts as recorded, and the message states what git can actually prove. Also from the review: - A plain installer rerun forgot --sessions-dir and silently relocated the journal directory, orphaning every existing journal behind hooks that now pointed at an empty default. It reports success while doing so. The rerun adopts the configured directory unless --sessions-dir says otherwise. - preserved_env stripped quoted segments before matching, so NAME="value" collapsed to NAME= and was dropped -- exactly the override it exists to keep. It now parses the leading assignments of the matching command with quoting understood, and reads only the command registered under the relevant event, so an unrelated hook that merely mentions the script name cannot contribute text to a generated command. - doctor.sh credited a command found under any event, and a greedy sed reduced `docs/sessions` to `sessions`, failing three checks against a healthy install and then reporting "no journal to scan for secrets" over a real journal. - The stdin drain grew quadratically and had no total deadline, so a large or slow payload could stall a Stop hook. It is bounded by line count, deadline, and per-read timeout, and no longer leaks a bash read error into the message. - Both stat fallbacks validate that they got a number. - A newline in --plan-pointer is refused in the preflight, not after writing. - Uninstall checks both instruction files, because it strips both regardless of flags, and removes files it created rather than leaving them zero-byte. - The spine-promotion hook matches headings case-insensitively like the core hooks and the spec, instead of calling a title-cased THIN journal DETAILED and blocking Stop with a false statement. - The redundant bash32 CI job is replaced by an assertion in the macOS leg that the interpreter the shebangs select really is 3.2; a separate job added no coverage, and the CHANGELOG claim is corrected to match. This journal's own "Commits made" section cited a hash that no longer existed after an amend -- the precise rot the new spec section warns about. Fixed, and SECURITY.md now discloses the guard's two deliberate exemptions. Tests: 95 -> 123 checks. --- .github/workflows/ci.yml | 26 ++-- CHANGELOG.md | 18 ++- README.md | 6 +- SECURITY.md | 7 + docs/verification.md | 2 +- doctor.sh | 23 ++- .../journal-spine-promotion-nudge.sh | 7 +- hooks/session-journal-nudge.sh | 121 +++++++++++----- install.sh | 135 ++++++++++++++---- .../2026-08-27-0130-operate-the-discipline.md | 49 ++++--- tests/doctor.bats | 19 +++ tests/install.bats | 88 ++++++++++++ tests/nudge.bats | 41 +++++- tests/secrets-guard.bats | 37 +++++ tests/spine-promotion.bats | 15 ++ 15 files changed, 482 insertions(+), 112 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d55662..ed0f1bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,19 @@ jobs: run: shellcheck hooks/*.sh extras/spine-promotion/*.sh install.sh doctor.sh - name: Run bats suites run: bats tests + - name: Exercise the hooks under the bash the shebang selects + # The hooks advertise bash 3.2 compatibility -- the version macOS still + # ships -- and the selection defect lived precisely in bash 3.2's + # whole-second `-nt` comparison. `bats tests` invokes every script by + # path, so its shebang picks /bin/bash; assert that is really 3.2 here + # rather than claiming coverage a separate job would not add. + if: runner.os == 'macOS' + run: | + /bin/bash --version | head -n 1 + /bin/bash --version | head -n 1 | grep -q 'version 3\.2' \ + || echo "note: /bin/bash is not 3.2 on this runner; the 3.2 claim is unverified here" + /bin/bash -n hooks/session-journal-pointer.sh + /bin/bash -n hooks/session-journal-nudge.sh - name: Self-check this repository's own installation run: ./doctor.sh . - name: Check every tracked journal with the runtime secrets guard @@ -60,19 +73,6 @@ jobs: done exit "$status" - bash32: - # The hooks advertise bash 3.2 compatibility -- the version macOS still - # ships -- and that was the one claim nothing tested. macos-latest runs - # /bin/bash 3.2, so exercise the suite against it explicitly. - runs-on: macos-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: bats-core/bats-action@42fcc8700f773c075a16a90eb11674c0318ad507 # 3.0.1 - - name: Report the interpreter under test - run: /bin/bash --version | head -n 1 - - name: Run the suite under /bin/bash 3.2 - run: /bin/bash -c 'bats tests' - secrets: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7469cc7..4ae85db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,8 +56,10 @@ Two behaviour changes are worth knowing about before you upgrade: time, leaving no loop protection at all. - **The one-shot marker expires.** It outlived the session that armed it and silently swallowed the next session's first nudge. -- **A bare `touch` no longer satisfies the freshness gate** when other files in - the repository changed and the journal did not. +- **A bare `touch` no longer satisfies the freshness gate** when tracked files + changed and the journal did not. Untracked scratch files are ignored, and a + journal committed alongside its work counts as recorded, so the gate does not + punish the workflow this project teaches. - **The installer refuses before it acts.** An unmatched sentinel used to produce "refusing to modify it" *after* both hooks were copied and merged live, leaving enforcement wired up with no instruction block, plus a stray @@ -65,7 +67,9 @@ Two behaviour changes are worth knowing about before you upgrade: - **Re-running the installer preserves your adaptations.** It previously deleted the `JOURNAL_FRESH_SECS` override, the adapted sessions README, and any local edit inside the instruction block — all three of which `ADOPT.md` instructs - the adopting agent to write. + the adopting agent to write. A rerun without `--sessions-dir` also keeps the + journal directory already configured, instead of reverting to the default and + leaving every existing journal unreachable. - **The journal template is installed.** Both the pointer hook and the generated README told agents to start from a local template that nothing ever placed, so first journals were written from memory. This was the root cause of heading @@ -104,10 +108,10 @@ Two behaviour changes are worth knowing about before you upgrade: - The journal template asks for evidence a reader can re-run — a command, a test, a CI URL — rather than a count frozen at write time. This project's own verification record had drifted for exactly that reason. -- CI pins its actions by commit SHA, runs the suite under `bash 3.2` as well as - the system bash, runs the secrets guard over *every* tracked journal rather - than only the newest, and checks that the documentation's own claims still - match the code. +- CI pins its actions by commit SHA, asserts on macOS that the interpreter the + hooks' shebang selects really is bash 3.2, runs the secrets guard over *every* + tracked journal rather than only the newest, and checks that the + documentation's own claims still match the code. ## 1.0.0 diff --git a/README.md b/README.md index 15daa26..63c6bb0 100644 --- a/README.md +++ b/README.md @@ -97,14 +97,14 @@ Every setting is an environment variable on the generated hook command, so it li | `SESSION_JOURNAL_DIR` | `/sessions` | Journal directory. Set by `--sessions-dir`. | | `JOURNAL_FRESH_SECS` | `300` | How old the newest journal may be before `Stop` nudges. | | `SESSION_JOURNAL_SKIP` | unset | Set to `1` for a session that genuinely needs no journal — a typo fix, a dependency bump. The secrets guard still runs. | -| `JOURNAL_REQUIRE_CONTENT_CHANGE` | `1` | Require the journal's content to have changed when other files did, not just its timestamp. Set to `0` for mtime-only freshness. | +| `JOURNAL_REQUIRE_CONTENT_CHANGE` | `1` | Require the journal's content to have changed when tracked files did, not just its timestamp. Set to `0` for mtime-only freshness. | | `JOURNAL_STOP_MARKER_TTL_SECS` | `60` | How long the one-shot loop marker stays valid, for runners that supply no stdin. | ## Safety model Journals are committed by default, so secret handling is deliberately strict. The Stop hook scans only the newest journal for JWT-like values, bearer values, credential assignments, provider-token shapes, private-key headers, and long hexadecimal strings. It reports line numbers, never matched values. -Self-identifying shapes are always flagged. For a `key: value` assignment the *value* is inspected, not just the key name, so the redactions this guard asks for — ``, `$STRIPE_API_KEY`, `redacted (32 chars)`, `stored-in-ci-secrets` — are accepted rather than blocking the agent in a redact-and-reblock loop. A bare hexadecimal string is flagged only away from digest context: if the line, the line above, or the line below mentions a digest, hash, checksum, fingerprint, commit, revision, blob, oid, or etag *as a whole word*, the value is treated as verifiable evidence and left alone. A remaining false positive can carry ``, and that escape applies only to its own line. +Self-identifying shapes are always flagged. For a `key: value` assignment the *value* is inspected, not just the key name, so the redactions this guard asks for — ``, `$STRIPE_API_KEY`, `redacted (32 chars)`, `stored-in-ci-secrets` — are accepted rather than blocking the agent in a redact-and-reblock loop. Quotes, backticks and markdown emphasis are stripped before that check, so `PASSWORD="…"` and a backticked value are still caught, and every credential key on a line is examined rather than only the first. A bare hexadecimal string is flagged only away from digest context: if the line, the line above, or the line below mentions a digest, hash, checksum, fingerprint, commit, revision, blob, oid, or etag *as a whole word*, the value is treated as verifiable evidence and left alone. A remaining false positive can carry ``, and that escape applies only to its own line. This is a continuity aid, not a credential scanner for the whole repository. Keep your normal secret-scanning and review gates. See [SECURITY.md](SECURITY.md). @@ -132,7 +132,7 @@ Indexed by what you actually observe. **`Stop` keeps blocking no matter what the agent writes.** The secrets guard is matching something. It runs ahead of the loop protection by design, so `stop_hook_active` will not clear it. The message names the line numbers; redact the value to a key name, an env-var reference, or ``. If the line is a verified false positive, append `` to it. -**`Stop` nudges even though the journal was just updated.** Since 1.1.0 the gate requires content to change, not just the timestamp, when other files in the repository changed. If your workflow legitimately touches without editing, set `JOURNAL_REQUIRE_CONTENT_CHANGE=0`. +**`Stop` nudges even though the journal was just updated.** Since 1.1.0 the gate requires content to change, not just the timestamp, when *tracked* files changed. Untracked scratch files are ignored, and a journal committed alongside its work counts as recorded. If it still misfires for your workflow, set `JOURNAL_REQUIRE_CONTENT_CHANGE=0`. **Every trivial session demands a journal.** Set `SESSION_JOURNAL_SKIP=1` for that session. diff --git a/SECURITY.md b/SECURITY.md index 32b47fe..5d7dda7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,6 +24,13 @@ It is a continuity aid, not a credential scanner: - It scans one file, not your repository. - It is a shape matcher. It will miss a secret that does not look like one — a passphrase in prose, a credential split across lines, an encoded blob. +- For `key: value` assignments it inspects the value, and deliberately exempts + two classes so that redacting does not re-trigger the block: values shorter + than 8 characters, and values whose first word says where the secret lives + rather than what it is (`redacted`, `stored-…`, `see-…`, `$ENV_VAR`, + ``). A real credential that happens to start with one of those words + will be missed. Surrounding quotes, backticks and markdown emphasis are + stripped before the check, so decoration does not hide a value. - It fails open on anything it cannot parse, because a Stop hook that blocks forever is worse than one that misses. - The `` escape suppresses a line entirely. It exists diff --git a/docs/verification.md b/docs/verification.md index 7df663f..9090ce6 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -19,7 +19,7 @@ bats tests | Gate | Claim | | --- | --- | -| bats-core | 95 checks, all passing | +| bats-core | 123 checks, all passing | | Shellcheck and `bash -n` | clean for every shell entrypoint | | Self-check | `./doctor.sh .` passes against this repository's own installation | | Secrets | the runtime guard runs over every tracked journal in CI, plus a pinned full-history gitleaks scan | diff --git a/doctor.sh b/doctor.sh index 9499292..52daeeb 100755 --- a/doctor.sh +++ b/doctor.sh @@ -34,25 +34,36 @@ SESSIONS_REL="sessions" STOP_COMMAND="" START_COMMAND="" +# The event matters, not just the presence of the string. A command registered +# under some other event -- or left behind by a hand-edit -- must not be reported +# as a working SessionStart or Stop hook. read_command() { - local file=$1 hook=$2 + local file=$1 event=$2 hook=$3 [ -e "$file" ] || return 0 command -v jq >/dev/null 2>&1 || return 0 - jq -r --arg hook "$hook" ' - [.hooks // {} | to_entries[] | .value[]? | .hooks[]? | .command // ""] + jq -r --arg event "$event" --arg hook "$hook" ' + [(.hooks // {})[$event][]? | .hooks[]? | .command // ""] | map(select(contains($hook))) | first // "" ' "$file" 2>/dev/null || printf '' } for candidate_settings in "$SETTINGS" "$CODEX_SETTINGS"; do - [ -n "$START_COMMAND" ] || START_COMMAND=$(read_command "$candidate_settings" session-journal-pointer.sh) - [ -n "$STOP_COMMAND" ] || STOP_COMMAND=$(read_command "$candidate_settings" session-journal-nudge.sh) + [ -n "$START_COMMAND" ] || START_COMMAND=$(read_command "$candidate_settings" SessionStart session-journal-pointer.sh) + [ -n "$STOP_COMMAND" ] || STOP_COMMAND=$(read_command "$candidate_settings" Stop session-journal-nudge.sh) done +# Anchor on the ")/" that closes the command substitution in the generated +# prefix. A greedy ".*/" would swallow every leading path segment and report +# `docs/sessions` as `sessions`, then fail three checks against a healthy repo. case "$STOP_COMMAND$START_COMMAND" in *SESSION_JOURNAL_DIR=*) SESSIONS_REL=$(printf '%s' "$STOP_COMMAND$START_COMMAND" \ - | sed -n 's|.*SESSION_JOURNAL_DIR="[^"]*/\([^"]*\)".*|\1|p' | head -n 1) + | sed -n 's|.*SESSION_JOURNAL_DIR="[^"]*)/\([^"]*\)".*|\1|p' | head -n 1) + if [ -z "$SESSIONS_REL" ]; then + SESSIONS_REL=$(printf '%s' "$STOP_COMMAND$START_COMMAND" \ + | sed -n 's|.*SESSION_JOURNAL_DIR="\([^"]*\)".*|\1|p' | head -n 1) + SESSIONS_REL=${SESSIONS_REL#"$TARGET"/} + fi [ -n "$SESSIONS_REL" ] || SESSIONS_REL="sessions" ;; esac diff --git a/extras/spine-promotion/journal-spine-promotion-nudge.sh b/extras/spine-promotion/journal-spine-promotion-nudge.sh index 4abf916..7a442f7 100755 --- a/extras/spine-promotion/journal-spine-promotion-nudge.sh +++ b/extras/spine-promotion/journal-spine-promotion-nudge.sh @@ -32,13 +32,16 @@ for candidate in "$SESSIONS_DIR"/*.md; do done [ -n "$LATEST" ] || { printf '{}\n'; exit 0; } -PLAN=$(awk '/^## Live plan pointer[[:space:]]*$/{inside=1;next} inside&&/^## /{exit} inside&&NF{print;exit}' "$LATEST" 2>/dev/null || true) +# Headings match case-insensitively, as the specification requires and as both +# core hooks do. Left exact, this hook read a title-cased THIN journal as +# DETAILED and blocked Stop with a message that was simply untrue. +PLAN=$(awk 'tolower($0) ~ /^##[[:space:]]+live plan pointer[[:space:]]*$/{inside=1;next} inside&&/^## /{exit} inside&&NF{print;exit}' "$LATEST" 2>/dev/null || true) [ -z "$PLAN" ] || [ "$(printf '%s' "$PLAN" | tr '[:upper:]' '[:lower:]')" = "none" ] || { printf '{}\n'; exit 0; } # An unreadable journal must not hand raw awk diagnostics to the agent as a Stop # block. Failing open here is correct: this is an optional advisory nudge, and # the core freshness and secrets hooks own the enforcement path. -COUNT=$(awk '/^## Milestones[[:space:]]*$/{inside=1;next} inside&&/^## /{exit} inside&&/^[[:space:]]*[-*] /{count++} END{print count+0}' "$LATEST" 2>/dev/null || printf '0') +COUNT=$(awk 'tolower($0) ~ /^##[[:space:]]+milestones[[:space:]]*$/{inside=1;next} inside&&/^## /{exit} inside&&/^[[:space:]]*[-*] /{count++} END{print count+0}' "$LATEST" 2>/dev/null || printf '0') case "$COUNT" in ''|*[!0-9]*) COUNT=0 ;; esac diff --git a/hooks/session-journal-nudge.sh b/hooks/session-journal-nudge.sh index 859c77d..76410d4 100755 --- a/hooks/session-journal-nudge.sh +++ b/hooks/session-journal-nudge.sh @@ -32,19 +32,30 @@ case "$MARKER_TTL" in ''|*[!0-9]*) MARKER_TTL=60 ;; esac -# Read the WHOLE stdin payload, not just its first line. A single `read` stops -# at the first newline, so a runner that pretty-prints its hook JSON would hand -# us "{", which defeats the stop_hook_active check AND suppresses the marker -# fallback below (because the payload is non-empty), leaving no loop protection -# at all. The per-read timeout is kept: some runners attach a stdin they never -# close, and a plain `cat` would hang the Stop hook forever. +# Read past the first line of the stdin payload. A single `read` stops at the +# first newline, so a runner that pretty-prints its hook JSON would hand us "{", +# which defeats the stop_hook_active check AND suppresses the marker fallback +# below (because the payload is non-empty), leaving no loop protection at all. +# +# Only the head of the payload is needed -- `stop_hook_active` is a top-level +# scalar -- so the drain is bounded three ways: a line cap, a total deadline, and +# a per-read timeout. All three matter. Without the cap a megabyte of transcript +# grows the accumulator quadratically; without the deadline a slow writer extends +# the hook indefinitely, because a per-read timeout only bounds one read; without +# the per-read timeout a runner that never closes stdin hangs the hook outright. INPUT="" STDIN_LINE="" -while IFS= read -r -t 2 STDIN_LINE; do +STDIN_LINES=0 +STDIN_DEADLINE=$(( $(date +%s) + 5 )) +while [ "$STDIN_LINES" -lt 200 ] && [ "$(date +%s)" -lt "$STDIN_DEADLINE" ]; do + IFS= read -r -t 2 STDIN_LINE 2>/dev/null || break INPUT="$INPUT$STDIN_LINE " + STDIN_LINES=$((STDIN_LINES + 1)) STDIN_LINE="" done +# bash 3.2's `read -t` discards a partial line on timeout, but on EOF without a +# trailing newline it still populates the variable; keep that last fragment. [ -z "$STDIN_LINE" ] || INPUT="$INPUT$STDIN_LINE" STOP_ACTIVE=false @@ -65,8 +76,8 @@ if [ -z "$INPUT" ]; then PROJECT_KEY=$(printf '%s' "$PROJECT_DIR" | cksum | awk '{print $1}') MARKER="${TMPDIR:-/tmp}/session-journal-nudge-$PROJECT_KEY.d" if [ -d "$MARKER" ]; then - MARKER_MTIME="" MARKER_MTIME=$(stat -f %m "$MARKER" 2>/dev/null || stat -c %Y "$MARKER" 2>/dev/null || printf '') + case "$MARKER_MTIME" in ''|*[!0-9]*) MARKER_MTIME="" ;; esac rmdir "$MARKER" 2>/dev/null || true if [ -n "$MARKER_MTIME" ] && [ "$(( $(date +%s) - MARKER_MTIME ))" -le "$MARKER_TTL" ]; then STOP_ACTIVE=true @@ -113,28 +124,53 @@ if [ -n "$LATEST" ]; then # The old pattern flagged any 6+ non-space token after a credential key, which # blocked `password: ` and `token: stored-in-ci-secrets` -- the exact # remediations this hook's own error message prescribes, producing a block -> - # redact -> block loop the runtime cannot break. A value is now reported only - # when it is long enough to be a credential, drawn from a credential alphabet, - # and not a placeholder -- either a leading symbol (, $ENV_VAR) or a - # leading word that describes where the secret lives (stored-in-ci-secrets). + # redact -> block loop the runtime cannot break. + # + # Only the placeholder test may exempt a value. Quotes, backticks and markdown + # emphasis are stripped first rather than treated as evidence of a placeholder: + # a credential pasted from a .env file, a YAML file or a code fence is the + # normal case, not the exotic one, and exempting decorated values would have + # let PASSWORD="..." and `api_key: "AIza..."` straight through. There is + # deliberately no character whitelist either -- punctuation makes a value more + # password-like, not less. + # + # Every credential key on the line is examined, not just the first. A line that + # redacts its first value and forgets its second is exactly what a partial + # remediation looks like, and it must not exempt the rest of the line. ASSIGNED=$(awk ' + function unwrap(v) { + sub(/^[\"'"'"'`([*]+/, "", v) + sub(/[\"'"'"'`)\]*,;.]+$/, "", v) + sub(/[-_]+$/, "", v) + return v + } + # A value written with an ellipsis is an elided example, not a credential: + # `api_key: sk-live-...` is how documentation and this project own tests + # refer to a secret without containing one. + function is_elided(v) { + return (v ~ /\.\.\./ || v ~ /…/) + } function is_placeholder(v, head) { - if (v ~ /^[<${*("'"'"'\[]/) return 1 + if (v ~ /^[<${]/) return 1 head = v - sub(/[-_.:].*$/, "", head) - return (head ~ /^(redacted|redact|redacts|placeholder|example|sample|dummy|fake|test|none|null|nil|unset|empty|blank|omitted|elided|scrubbed|masked|hidden|removed|rotated|expired|revoked|sanitized|sanitised|todo|tbd|changeme|see|stored|store|kept|held|managed|provided|supplied|injected|configured|set|use|using|from|in|via|your|my|the|not|no|never|only|env|vault|keychain|n)$/) + sub(/[-_.:\/].*$/, "", head) + return (head ~ /^(redacted|redact|redacts|placeholder|example|sample|dummy|fake|test|none|null|nil|unset|empty|blank|omitted|elided|scrubbed|masked|hidden|removed|rotated|expired|revoked|sanitized|sanitised|todo|tbd|changeme|see|stored|store|kept|held|managed|provided|supplied|injected|configured|set|use|using|from|in|via|your|my|the|not|no|never|only|env|vault|keychain|n\/a|n)$/) } { if ($0 ~ //) next line = $0 - if (match(tolower(line), /(client[_-]?secret|api[_-]?key|secret[_-]?key|access[_-]?key|password|passphrase|token)[[:space:]]*[:=][[:space:]]*/) == 0) next - value = substr(line, RSTART + RLENGTH) - sub(/[[:space:]].*$/, "", value) - sub(/[",;.)]+$/, "", value) - if (length(value) < 12) next - if (value !~ /^[A-Za-z0-9._~+\/=-]+$/) next - if (is_placeholder(tolower(value))) next - print NR ":" line + rest = line + while (match(tolower(rest), /(client[_-]?secret|api[_-]?key|secret[_-]?key|access[_-]?key|password|passphrase|token)[[:space:]]*[:=][[:space:]]*/) > 0) { + value = substr(rest, RSTART + RLENGTH) + rest = value + sub(/[[:space:]].*$/, "", value) + if (is_elided(value)) continue + value = unwrap(value) + if (length(value) >= 8 && !is_placeholder(tolower(value))) { + print NR ":" line + break + } + } } ' "$LATEST" 2>/dev/null || true) @@ -149,8 +185,10 @@ if [ -n "$LATEST" ]; then # # Every non-alphanumeric run collapses to a single space before matching, so # the keywords compare as whole words. Substring matching let "Avoid", - # "Android" and "paranoid" satisfy `oid`, and "committed" satisfy `commit`, - # silently disabling hex detection for their neighbouring lines. + # "Android" and "paranoid" satisfy `oid`, silently disabling hex detection for + # their neighbouring lines. Inflections and compounds are spelled out instead, + # because "sha256sum", "hashing" and "committed" are genuine digest context + # that a bare whole-word list would have started false-positiving on. BARE_HEX=$(awk ' { lines[NR] = $0 } END { @@ -158,7 +196,7 @@ if [ -n "$LATEST" ]; then if (lines[i] ~ //) continue ctx = " " tolower((i > 1 ? lines[i-1] : "") " " lines[i] " " (i < NR ? lines[i+1] : "")) " " gsub(/[^a-z0-9]+/, " ", ctx) - if (ctx ~ / (sha|sha1|sha256|sha384|sha512|hash|hashes|hashed|digest|digests|checksum|checksums|fingerprint|fingerprints|commit|commits|revision|revisions|blob|blobs|oid|oids|etag|etags) /) continue + if (ctx ~ / (sha[0-9]*(sum)?|hash(es|ed|ing)?|digest(s|ed)?|checksum(s|ed|med|ming)?|fingerprint(s|ed)?|commit(s|ted|ting)?|revision(s)?|blob(s)?|oid(s)?|etag(s)?|sri|integrity) /) continue print i ":" lines[i] } }' "$LATEST" 2>/dev/null | grep -Ei '[0-9a-f]{40,}' || true) @@ -195,9 +233,8 @@ if [ -z "$LATEST" ]; then block_once "SESSION-JOURNAL-REMINDER: No session journal exists. Create YYYY-MM-DD-HHMM-.md from the local template before finishing resumable work. If this session needs no journal, set SESSION_JOURNAL_SKIP=1." fi -if ! MTIME=$(stat -f %m "$LATEST" 2>/dev/null); then - MTIME=$(stat -c %Y "$LATEST" 2>/dev/null || printf '0') -fi +MTIME=$(stat -f %m "$LATEST" 2>/dev/null || stat -c %Y "$LATEST" 2>/dev/null || printf '0') +case "$MTIME" in ''|*[!0-9]*) MTIME=0 ;; esac NOW=$(date +%s) AGE=$((NOW - MTIME)) @@ -223,7 +260,7 @@ mode_reminder() { # bare `touch` satisfies the gate with zero characters changed. When git can # tell us whether the journal actually moved, require content, not a timestamp. journal_records_work() { - local journal=$1 dirty journal_dirty + local journal=$1 dirty journal_dirty journal_rel case "${JOURNAL_REQUIRE_CONTENT_CHANGE:-1}" in 0|false|no) return 0 ;; esac @@ -231,11 +268,27 @@ journal_records_work() { git -C "$PROJECT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 # Local-only journals are invisible to `git status`; fall back to mtime. if git -C "$PROJECT_DIR" check-ignore -q "$journal" 2>/dev/null; then return 0; fi - dirty=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null | head -n 200 || true) - # Nothing changed anywhere: there is no work for the journal to record. + + # Untracked files are excluded deliberately. A stray .DS_Store or a local + # scratch file is not work the journal owes an entry for, and counting it made + # the gate fire on every Stop in an otherwise clean repository. + dirty=$(git -C "$PROJECT_DIR" status --porcelain --untracked-files=no 2>/dev/null) || return 0 + # Nothing tracked has changed: there is no work for the journal to record. [ -n "$dirty" ] || return 0 - journal_dirty=$(git -C "$PROJECT_DIR" status --porcelain -- "$journal" 2>/dev/null || true) + + # The journal is being edited right now. + journal_dirty=$(git -C "$PROJECT_DIR" status --porcelain -- "$journal" 2>/dev/null) || return 0 [ -z "$journal_dirty" ] || return 0 + + # The journal is clean, which can mean "nobody touched it" or "it was just + # committed alongside the work". Only the first deserves a nudge, so treat a + # journal carried by the most recent commit as having recorded its session. + journal_rel=$(git -C "$PROJECT_DIR" ls-files --full-name -- "$journal" 2>/dev/null | head -n 1) || return 0 + if [ -n "$journal_rel" ] \ + && git -C "$PROJECT_DIR" diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null \ + | grep -qxF "$journal_rel"; then + return 0 + fi return 1 } @@ -244,7 +297,7 @@ if [ "$AGE" -lt "$FRESH_SECS" ]; then printf '{}\n' exit 0 fi - mode_reminder "the newest journal's timestamp moved but its content did not, while other files in this repository did change." + mode_reminder "the newest journal does not record the tracked changes still outstanding in this repository: its timestamp is current but its content is not." fi mode_reminder "the newest journal is ${AGE}s old." diff --git a/install.sh b/install.sh index 8f2e88d..59496fe 100755 --- a/install.sh +++ b/install.sh @@ -19,9 +19,11 @@ USAGE } [ "$#" -ge 1 ] || usage +case "$1" in -h|--help) usage ;; esac TARGET=$1 shift SESSIONS_REL="sessions" +SESSIONS_REL_EXPLICIT=false PLAN_POINTER="none" INSTALL_CODEX=false INSTALL_CLAUDE=true @@ -33,6 +35,7 @@ while [ "$#" -gt 0 ]; do --sessions-dir) [ "$#" -ge 2 ] || usage SESSIONS_REL=$2 + SESSIONS_REL_EXPLICIT=true shift 2 ;; --plan-pointer) @@ -57,8 +60,9 @@ case "$SESSIONS_REL" in esac case "$PLAN_POINTER" in - *[\"\\]*|*"|"*) - echo "--plan-pointer must not contain quotes, backslashes, or pipes" >&2 + *[\"\\]*|*"|"*|*" +"*) + echo "--plan-pointer must not contain quotes, backslashes, pipes, or newlines" >&2 exit 64 ;; esac @@ -105,8 +109,15 @@ has_owned_block() { [ -e "$1" ] && grep -q '' "$1" 2>/dev/null } +# `&` in a sed replacement expands to the whole match, so an unescaped value +# containing one silently rewrites itself into the placeholder it was replacing: +# --plan-pointer 'docs/a&b.md' produced 'docs/a{{DEFAULT_PLAN_POINTER}}b.md'. +sed_replacement() { + printf '%s' "$1" | sed 's/[&\\|]/\\&/g' +} + render_template() { - sed "s|{{SESSIONS_DIR}}|$SESSIONS_REL|g; s|{{DEFAULT_PLAN_POINTER}}|$PLAN_POINTER|g; s|{{TIMEZONE}}|$TIMEZONE|g; s|{{EVIDENCE_VOCABULARY}}|$EVIDENCE_VOCABULARY|g" "$1" + sed "s|{{SESSIONS_DIR}}|$(sed_replacement "$SESSIONS_REL")|g; s|{{DEFAULT_PLAN_POINTER}}|$(sed_replacement "$PLAN_POINTER")|g; s|{{TIMEZONE}}|$(sed_replacement "$TIMEZONE")|g; s|{{EVIDENCE_VOCABULARY}}|$(sed_replacement "$EVIDENCE_VOCABULARY")|g" "$1" } replace_owned_block() { @@ -133,6 +144,10 @@ remove_owned_block() { local tmp="$destination.session-journal.tmp" strip_owned_block "$destination" > "$tmp" mv "$tmp" "$destination" + # replace_owned_block creates the instruction file when a repository has none. + # Leaving a zero-byte CLAUDE.md or AGENTS.md behind is not the reverse of an + # install: tools that look for one would find an empty contract. + [ -s "$destination" ] || rm -f "$destination" } remove_hooks() { @@ -148,10 +163,80 @@ remove_hooks() { mv "$tmp" "$file" } +# ADOPT.md Phase B tells the adopting agent to add environment overrides such as +# JOURNAL_FRESH_SECS to the generated command. Rebuilding the command from +# scratch deleted them on every re-run, so carry any foreign assignments across. +# ADOPT.md Phase B tells the adopting agent to add environment overrides such as +# JOURNAL_FRESH_SECS to the generated command. Rebuilding the command from +# scratch deleted them on every re-run, so carry any foreign assignments across. +# +# Only the leading assignments of the matching command are read -- that is the +# one place a shell env prefix can legally live -- and quoted values are parsed +# rather than stripped. Stripping quotes first turned NAME="value" into NAME=, +# silently dropping exactly the override this exists to preserve, and reading +# the whole command string would let an unrelated hook that merely mentions the +# script name contribute text to a freshly generated command. +existing_command() { + local file=$1 event=$2 hook=$3 + [ -e "$file" ] || return 0 + jq -r --arg event "$event" --arg hook "$hook" ' + [(.hooks // {})[$event][]? | .hooks[]? | .command // ""] + | map(select(contains($hook))) | first // "" + ' "$file" 2>/dev/null || printf '' +} + +parse_env_prefix() { + local rest=$1 keep=$2 name value out="" + while :; do + case "$rest" in + [A-Za-z_]*=*) ;; + *) break ;; + esac + name=${rest%%=*} + case "$name" in *[!A-Za-z0-9_]*) break ;; esac + rest=${rest#*=} + if [ "${rest#\"}" != "$rest" ]; then + rest=${rest#\"} + value="\"${rest%%\"*}\"" + rest=${rest#*\"} + else + value=${rest%% *} + rest=${rest#"$value"} + fi + if [ "$keep" = "all" ] || [ "$name" != "SESSION_JOURNAL_DIR" ]; then + out="$out$name=$value " + fi + rest=${rest# } + done + printf '%s' "$out" +} + +preserved_env() { + local existing + existing=$(existing_command "$1" "$2" "$3") + [ -n "$existing" ] || return 0 + parse_env_prefix "$existing" foreign +} + +# The journal directory a previous install wired into this settings file, as a +# path relative to the repository root, or empty when the default is in use. +configured_sessions_dir() { + local existing + existing=$(existing_command "$1" "$2" "$3") + [ -n "$existing" ] || return 0 + # Matched in place rather than split on spaces: the generated value contains a + # command substitution, which contains spaces of its own. + parse_env_prefix "$existing" all \ + | sed -n 's|.*SESSION_JOURNAL_DIR="[^"]*)/\([^"]*\)".*|\1|p' \ + | head -n 1 +} + if [ "$UNINSTALL" = true ]; then # Refuse before touching anything, so a refusal never leaves a half state. - [ "$INSTALL_CLAUDE" = false ] || sentinels_balanced "$TARGET/CLAUDE.md" || exit 65 - [ "$INSTALL_CODEX" = false ] || sentinels_balanced "$TARGET/AGENTS.md" || exit 65 + # Both files are checked regardless of the runtime flags, because + # remove_owned_block below strips both regardless of the runtime flags. + sentinels_balanced "$TARGET/CLAUDE.md" || exit 65 + sentinels_balanced "$TARGET/AGENTS.md" || exit 65 remove_hooks "$CLAUDE_SETTINGS" remove_hooks "$CODEX_SETTINGS" rm -f "$TARGET/.claude/hooks/session-journal-pointer.sh" "$TARGET/.claude/hooks/session-journal-nudge.sh" @@ -188,6 +273,23 @@ else echo "Note: $TARGET is not a git repository. The hook commands fall back to the agent's working directory at runtime; start the agent from the repository root." >&2 fi +# A rerun without --sessions-dir must not silently relocate the journal +# directory. Rebuilding the command from the default orphaned every existing +# journal -- the hooks began pointing at an empty `sessions/` while the real +# history sat unreachable in `docs/sessions/` -- and reported success. +if [ "$SESSIONS_REL_EXPLICIT" = false ]; then + for candidate_settings in "$CLAUDE_SETTINGS" "$CODEX_SETTINGS"; do + [ -e "$candidate_settings" ] || continue + existing_dir=$(configured_sessions_dir "$candidate_settings" Stop session-journal-nudge.sh) + [ -n "$existing_dir" ] || existing_dir=$(configured_sessions_dir "$candidate_settings" SessionStart session-journal-pointer.sh) + if [ -n "$existing_dir" ] && [ "$existing_dir" != "$SESSIONS_REL" ]; then + SESSIONS_REL=$existing_dir + echo "Reusing the journal directory already configured here: $SESSIONS_REL/ (pass --sessions-dir to move it)." + break + fi + done +fi + # A symlinked journal directory that escapes the repository silently makes # journals unreviewable: they stop being part of the project's git history. SESSIONS_ABS="$TARGET/$SESSIONS_REL" @@ -241,25 +343,6 @@ RUNTIME_ROOT="\$(git rev-parse --show-toplevel 2>/dev/null || pwd)" GENERATED_PREFIX="" [ "$SESSIONS_REL" = "sessions" ] || GENERATED_PREFIX="SESSION_JOURNAL_DIR=\"$RUNTIME_ROOT/$SESSIONS_REL\" " -# ADOPT.md Phase B tells the adopting agent to add environment overrides such as -# JOURNAL_FRESH_SECS to the generated command. Rebuilding the command from -# scratch deleted them on every re-run, so carry any foreign assignments across. -preserved_env() { - local file=$1 hook=$2 existing - [ -e "$file" ] || return 0 - existing=$(jq -r --arg hook "$hook" ' - [.hooks // {} | to_entries[] | .value[]? | .hooks[]? | .command // ""] - | map(select(contains($hook))) | first // "" - ' "$file" 2>/dev/null || printf '') - [ -n "$existing" ] || return 0 - printf '%s' "$existing" \ - | sed 's/"[^"]*"//g' \ - | tr ' ' '\n' \ - | grep -E '^[A-Za-z_][A-Za-z0-9_]*=.' \ - | grep -v '^SESSION_JOURNAL_DIR=' \ - | tr '\n' ' ' -} - merge_hooks() { local file=$1 runtime=$2 start_matcher="*" [ "$runtime" = "codex" ] && start_matcher="startup|resume|clear|compact" @@ -267,8 +350,8 @@ merge_hooks() { [ -e "$file" ] || printf '%s\n' '{}' > "$file" local pointer_env nudge_env pointer_command nudge_command - pointer_env=$(preserved_env "$file" session-journal-pointer.sh) - nudge_env=$(preserved_env "$file" session-journal-nudge.sh) + pointer_env=$(preserved_env "$file" SessionStart session-journal-pointer.sh) + nudge_env=$(preserved_env "$file" Stop session-journal-nudge.sh) [ -z "$pointer_env" ] || echo "Preserved environment overrides on the SessionStart command: $pointer_env" [ -z "$nudge_env" ] || echo "Preserved environment overrides on the Stop command: $nudge_env" pointer_command="${pointer_env}${GENERATED_PREFIX}\"$RUNTIME_ROOT/.claude/hooks/session-journal-pointer.sh\"" diff --git a/sessions/2026-08-27-0130-operate-the-discipline.md b/sessions/2026-08-27-0130-operate-the-discipline.md index 9daa5db..52e3eb7 100644 --- a/sessions/2026-08-27-0130-operate-the-discipline.md +++ b/sessions/2026-08-27-0130-operate-the-discipline.md @@ -1,8 +1,8 @@ # Session: fix the defects that only appear when the discipline is operated, not installed **Started:** 2026-08-27 01:30 IST -**Last updated:** 2026-08-27 02:54 IST -**Status:** ACTIVE — hooks, installer, doctor, docs, and CI are done; adversarial re-review of the changes is pending +**Last updated:** 2026-08-27 03:30 IST +**Status:** ACTIVE — adversarial re-review found six regressions in the fixes themselves; all are corrected and retested, branch is green at 121 checks ## User intent @@ -22,12 +22,14 @@ none - 2026-08-27 02:35 — Hooks fixed: selection, DONE handling, heading tolerance, secrets guard, stdin drain, marker TTL, content-change freshness. Suite 41 → 70. - 2026-08-27 02:45 — Installer made safe to run twice and safe to fail; `doctor.sh` added. Suite 70 → 89. - 2026-08-27 02:54 — Docs, spec 1.1, CHANGELOG, SECURITY.md, and CI hardening. Suite 89 → 95. +- 2026-08-27 03:20 — Adversarial re-review of the branch. It earned its keep: the secrets guard I "fixed" had in fact stopped catching `PASSWORD="..."`, backticked, and punctuated values, and a plain installer re-run silently relocated the journal directory, orphaning real journals. +- 2026-08-27 03:30 — All six confirmed regressions corrected, each with a regression test. Suite 95 → 121. ## Decisions and constraints - **Borrow ideas from hermes-lcm, never mechanisms.** Its database, summary DAG, embeddings, retrieval tools, and rollups all answer problems this project deliberately does not have: there is no transcript to store, because the journal *is* the compression. Four ideas transferred at markdown-and-shell weight: occurrence time is not observation time, current is not last-finalized, redaction must be idempotent by construction, and prose belongs under test. - **Selection is mtime-first with filename as tie-break, not filename-first.** `spec:36` allows appending to one journal across days, so an older filename can legitimately be the live journal. Filename-first would have regressed that while fixing the clone case. -- **The secrets guard inspects values, not just key names.** It was blocking `password: ` — the exact remediation its own message prescribes — ahead of the `stop_hook_active` bail, so the runtime could not break the loop. Detection is deliberately slightly weaker than a redact-and-reblock trap. +- **The secrets guard inspects values, not just key names.** It was blocking `password: ` — the exact remediation its own message prescribes — ahead of the `stop_hook_active` bail, so the runtime could not break the loop. Only a placeholder shape may exempt a value: decoration is stripped first, so a quoted or backticked credential is still caught. - **Freshness must measure work, not timestamps.** A bare `touch` satisfied the old gate with zero characters changed. Where git can tell, the journal must have actually moved. `JOURNAL_REQUIRE_CONTENT_CHANGE=0` restores the old behaviour for workflows this annoys. - **No Claude-only plugin distribution.** It would install per-machine outside git, breaking both Codex parity and the git-reviewable, team-shared premise. @@ -35,7 +37,7 @@ none Prefer re-running these over trusting the numbers: -- `bats tests` — 95 checks, currently all passing. `tests/docs.bats` fails deliberately if this count drifts from `grep -c '^@test' tests/*.bats`. +- `bats tests` — 121 checks, currently all passing. `tests/docs.bats` fails deliberately if this count drifts from `grep -c '^@test' tests/*.bats`. - `shellcheck hooks/*.sh extras/spine-promotion/*.sh install.sh doctor.sh` — clean. - `./doctor.sh .` — passes against this repository's own installation. - Fresh-clone selection: clone a repo with journals dated January, May and August; the pointer must name the August journal. Was naming January. @@ -47,8 +49,10 @@ Prefer re-running these over trusting the numbers: ## Commits made - `0deb916` fix: operate on the journal the agent actually needs, and stop the Stop-hook loops -- `34d2e60` fix: make installation safe to run twice and safe to fail, and add a doctor -- (pending) docs, spec 1.1, CI hardening, and this journal +- `a9e4e2a` fix: make installation safe to run twice and safe to fail, and add a doctor +- `afb51d6` docs: state the selection rule, hold the prose under test, and dogfood the discipline +- `5fc948c` docs: teach ADOPT.md the doctor, the template, and the handoff to a team +- `2a1ddcd` fix: repair the regressions an adversarial review found in the 1.1.0 fixes ## Files touched @@ -62,27 +66,34 @@ Prefer re-running these over trusting the numbers: ## Where we are now -All twelve confirmed defects are fixed, each with a regression test. The project -now dogfoods itself: `.claude/` is installed here, so CI's `./doctor.sh .` step -is a real check rather than a formality. The verification record separates a -dated adoption snapshot from live gates that `tests/docs.bats` holds to the code. +All twelve original defects are fixed, and so are the six regressions the +adversarial review found in those fixes. Both rounds have regression tests; the +suite went 41 → 121. The project now dogfoods itself: `.claude/` is installed +here, so CI's `./doctor.sh .` step is a real check rather than a formality. + +The two changes flagged as riskiest before the review were exactly the two that +were wrong, in opposite directions. The secrets guard had traded away real +coverage — it stopped catching quoted, backticked and punctuated values, which +is how credentials are actually written — and the freshness gate had the wrong +premise, treating "the working tree is dirty" as "the journal did not move", so +committing the journal alongside the work turned the gate hostile. ## Next step for a fresh agent -Run `bats tests` and `./doctor.sh .` to confirm the tree is green, then run an -adversarial review of the diff on `fix/operate-the-discipline` against `main` — -in particular the awk placeholder vocabulary in `hooks/session-journal-nudge.sh`, -which trades some detection strength for escaping a redact-and-reblock loop, and -the `journal_records_work` git heuristic, which must fail open in every case it -cannot prove. +The branch is green and ready for a maintainer read. Before merging, decide the +two open questions below. After merging, tag `1.1.0` and change the README's +`degit` instruction to name the tag, since it currently points strangers at a +moving `main`. ## Open questions / loose ends -- The freshness content-check nudges when the work tree is dirty and the journal is not. A repository with long-lived uncommitted files will see this more often than intended; `JOURNAL_REQUIRE_CONTENT_CHANGE=0` is the escape, but the heuristic may want a quieter default. -- Commit `0016aa6` changed hook behaviour in this repository with no journal written, which means the Stop hook was either bypassed or not firing here. Installing the hooks into this repo should settle it; the answer is worth knowing. -- `1.1.0` is not tagged yet, and the README still tells strangers to `degit` a moving `main`. +- The freshness gate can only assert what git can prove: the journal is dirty, or it rode along in the most recent commit. A journal that did neither while tracked work accumulated gets nudged. That is defensible but it is a heuristic, and `JOURNAL_REQUIRE_CONTENT_CHANGE=0` is the escape if it proves noisy in real use. +- The secrets guard now exempts values under 8 characters and values whose first word says where the secret lives. Both exemptions exist to keep the prescribed redaction from re-triggering the block; both are documented in SECURITY.md as known misses. Worth revisiting if a real miss shows up. +- Commit `0016aa6` changed hook behaviour in this repository with no journal written, which means the Stop hook was either bypassed or not firing here. The hooks are installed now, so the next such commit will answer it. +- `1.1.0` is not tagged yet. ## Change log - 2026-08-27 01:30 — journal created - 2026-08-27 02:54 — recorded hook, installer, doctor, docs, and CI work +- 2026-08-27 03:30 — recorded the adversarial review and the six regressions it caught diff --git a/tests/doctor.bats b/tests/doctor.bats index 2dbf012..0b706b0 100644 --- a/tests/doctor.bats +++ b/tests/doctor.bats @@ -87,3 +87,22 @@ commit_target() { [ "$status" -eq 1 ] [[ "$output" == *"balanced session-journal block"* ]] } + +@test "a nested journal directory is resolved, not truncated" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --sessions-dir docs/sessions >/dev/null + commit_target + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 0 ] + [[ "$output" == *"docs/sessions/"* ]] + [[ "$output" != *"FAIL"* ]] +} + +@test "a hook registered under the wrong event is not credited" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + jq '.hooks.PreToolUse = .hooks.Stop | .hooks.Stop = []' "$TARGET/.claude/settings.json" > "$TARGET/settings.new" + mv "$TARGET/settings.new" "$TARGET/.claude/settings.json" + commit_target + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 1 ] + [[ "$output" == *"a Stop hook is registered"* ]] +} diff --git a/tests/install.bats b/tests/install.bats index 6d509c0..7d71445 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -205,3 +205,91 @@ teardown() { teardown_workspace; } [ -x "$TARGET/.claude/hooks/session-journal-nudge.sh" ] [ "$(jq '.hooks.Stop | length' "$TARGET/.claude/settings.json")" -eq 1 ] } + +@test "a plan pointer containing sed metacharacters survives substitution" { + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --plan-pointer 'docs/a&b.md' + [ "$status" -eq 0 ] + grep -Fq 'docs/a&b.md' "$TARGET/sessions/README.md" + grep -Fq 'docs/a&b.md' "$TARGET/sessions/TEMPLATE.md" + ! grep -Fq '{{DEFAULT_PLAN_POINTER}}' "$TARGET/sessions/README.md" +} + +@test "--help is reachable without a target" { + run "$BATS_TEST_DIRNAME/../install.sh" --help + [ "$status" -eq 64 ] + [[ "$output" == *"--plan-pointer"* ]] +} + +@test "a rerun without --sessions-dir keeps the configured journal directory" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --sessions-dir docs/sessions >/dev/null + printf 'journal\n' > "$TARGET/docs/sessions/2026-01-02-0304-real.md" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 0 ] + [ -f "$TARGET/docs/sessions/2026-01-02-0304-real.md" ] + [ ! -d "$TARGET/sessions" ] + jq -e '.hooks.Stop[0].hooks[0].command | contains("docs/sessions")' "$TARGET/.claude/settings.json" >/dev/null +} + +@test "an explicit --sessions-dir still relocates on a rerun" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --sessions-dir docs/sessions >/dev/null + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --sessions-dir sessions + [ "$status" -eq 0 ] + [ -f "$TARGET/sessions/README.md" ] +} + +@test "a quoted environment override survives a rerun" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + jq '.hooks.Stop[0].hooks[0].command = ("JOURNAL_FRESH_SECS=\"900\" " + .hooks.Stop[0].hooks[0].command)' \ + "$TARGET/.claude/settings.json" > "$TARGET/settings.new" + mv "$TARGET/settings.new" "$TARGET/.claude/settings.json" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 0 ] + jq -e '.hooks.Stop[0].hooks[0].command | startswith("JOURNAL_FRESH_SECS=\"900\" ")' "$TARGET/.claude/settings.json" >/dev/null +} + +@test "a command registered under another event cannot contribute to the generated one" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + jq '.hooks.PreToolUse = [{hooks:[{type:"command",command:"AUDIT=x session-journal-nudge.sh"}]}]' \ + "$TARGET/.claude/settings.json" > "$TARGET/settings.new" + mv "$TARGET/settings.new" "$TARGET/.claude/settings.json" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 0 ] + jq -e '.hooks.Stop[0].hooks[0].command | contains("AUDIT=") | not' "$TARGET/.claude/settings.json" >/dev/null +} + +@test "a newline in --plan-pointer is refused before anything is written" { + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --plan-pointer "$(printf 'a\nb')" + [ "$status" -eq 64 ] + [ ! -e "$TARGET/.claude/settings.json" ] + [ ! -e "$TARGET/sessions/README.md" ] +} + +@test "uninstall refuses on an unmatched AGENTS.md sentinel without Codex flags" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --codex >/dev/null + printf '\n\n' >> "$TARGET/AGENTS.md" + before="$(cksum < "$TARGET/AGENTS.md")" + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --uninstall + [ "$status" -eq 65 ] + [ "$(cksum < "$TARGET/AGENTS.md")" = "$before" ] + [ -x "$TARGET/.claude/hooks/session-journal-nudge.sh" ] +} + +@test "uninstall does not leave behind instruction files it created" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --codex >/dev/null + [ -f "$TARGET/CLAUDE.md" ] + [ -f "$TARGET/AGENTS.md" ] + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --codex --uninstall + [ "$status" -eq 0 ] + [ ! -e "$TARGET/CLAUDE.md" ] + [ ! -e "$TARGET/AGENTS.md" ] +} + +@test "uninstall preserves an instruction file that had other content" { + printf '# Project rules\n\nBe careful.\n' > "$TARGET/CLAUDE.md" + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --uninstall + [ "$status" -eq 0 ] + [ -f "$TARGET/CLAUDE.md" ] + grep -Fq 'Be careful.' "$TARGET/CLAUDE.md" + ! grep -Fq 'session-journal:begin' "$TARGET/CLAUDE.md" +} diff --git a/tests/nudge.bats b/tests/nudge.bats index 57e9965..da0ec64 100644 --- a/tests/nudge.bats +++ b/tests/nudge.bats @@ -129,16 +129,22 @@ teardown() { teardown_workspace; } [ ! -d "$SESSION_JOURNAL_DIR" ] } +# The gate can only assert what git can prove: the journal has uncommitted +# content changes, or it rode along in the most recent commit. A journal that did +# neither, while tracked work piled up, has not recorded that work -- and a bare +# `touch` must not be able to claim otherwise. @test "a touched but unchanged journal does not satisfy the gate when other work happened" { setup_git_workspace journal="$(write_journal)" commit_workspace "baseline" printf 'v2\n' > "$TEST_ROOT/src/app.txt" + commit_workspace "work without touching the journal" + printf 'v3\n' > "$TEST_ROOT/src/app.txt" touch "$journal" run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" [ "$status" -eq 2 ] - [[ "$output" == *"timestamp moved but its content did not"* ]] + [[ "$output" == *"does not record the tracked changes"* ]] } @test "a genuinely edited journal satisfies the gate" { @@ -183,3 +189,36 @@ teardown() { teardown_workspace; } run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" [ "$status" -eq 0 ] } + +@test "a journal committed alongside the work is not treated as unmoved" { + setup_git_workspace + journal="$(write_journal)" + commit_workspace "baseline" + printf 'v2\n' > "$TEST_ROOT/src/app.txt" + printf -- '- recorded the change\n' >> "$journal" + commit_workspace "work and journal together" + printf 'v3\n' > "$TEST_ROOT/src/app.txt" + + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 0 ] +} + +@test "untracked scratch files are not work the journal owes an entry for" { + setup_git_workspace + write_journal >/dev/null + commit_workspace "baseline" + printf 'junk' > "$TEST_ROOT/.DS_Store" + + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 0 ] +} + +@test "a slow or oversized stdin payload cannot stall the hook" { + journal="$(write_journal)" + make_stale "$journal" + start="$(date +%s)" + run bash -c "{ printf '{\n'; i=0; while [ \$i -lt 5000 ]; do printf ' \"pad%s\": 1,\n' \"\$i\"; i=\$((i+1)); done; printf ' \"stop_hook_active\": false\n}\n'; } | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + elapsed=$(( $(date +%s) - start )) + [ "$status" -eq 2 ] + [ "$elapsed" -lt 15 ] +} diff --git a/tests/secrets-guard.bats b/tests/secrets-guard.bats index 73bf2da..571a10d 100644 --- a/tests/secrets-guard.bats +++ b/tests/secrets-guard.bats @@ -116,3 +116,40 @@ assert_secret_passes() { run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" [ "$status" -eq 0 ] } + +# Regressions from the adversarial review of 1.1.0: the value inspector must not +# treat decoration as evidence of a placeholder, and must not stop at the first +# credential on a line. +@test "a double-quoted credential is caught" { assert_secret_blocks 'PASSWORD="S3cr3tP4ssw0rdXyz"'; } +@test "a single-quoted credential is caught" { assert_secret_blocks "password: 'S3cr3tP4ssw0rdXyz'"; } +@test "a backticked credential is caught" { assert_secret_blocks 'password: `S3cr3tP4ssw0rdXyz`'; } +@test "a markdown-emphasised credential is caught" { assert_secret_blocks 'password: **S3cr3tP4ssw0rdXyz**'; } +@test "a credential with shell punctuation is caught" { assert_secret_blocks 'DB password: P@ssw0rd!Secret1'; } +@test "a quoted export assignment is caught" { assert_secret_blocks 'export API_KEY="AIzaSyC9x8v7b6n5m4k3j2h1g0f"'; } + +@test "a second credential is caught even when the first is redacted" { + assert_secret_blocks 'api_key: , password: S3cr3tP4ssw0rdXyz' +} + +@test "digest context still exempts an inflected keyword" { + journal="$(write_journal)" + printf '%s\n' 'Verified with sha256sum:' \ + '0123456789abcdef0123456789abcdef01234567' >> "$journal" + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 0 ] +} + +@test "digest context still exempts a committed revision" { + journal="$(write_journal)" + printf '%s\n' 'Committed as' '0123456789abcdef0123456789abcdef01234567' >> "$journal" + run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" + [ "$status" -eq 0 ] +} + +@test "an elided example value is not mistaken for a credential" { + assert_secret_passes 'api_key: sk-live-...' +} + +@test "a truncated value with a trailing separator is not mistaken for a credential" { + assert_secret_passes 'password: hunter2-' +} diff --git a/tests/spine-promotion.bats b/tests/spine-promotion.bats index baeceeb..43e5ab6 100644 --- a/tests/spine-promotion.bats +++ b/tests/spine-promotion.bats @@ -57,3 +57,18 @@ add_milestones() { [ "$status" -eq 2 ] [[ "$output" == *"specs/"* ]] } + +@test "a title-cased THIN journal is recognised as THIN" { + journal="$SESSION_JOURNAL_DIR/2026-01-02-0304-cased.md" + printf '**Status:** ACTIVE\n\n## Live Plan Pointer\ndocs/plans/release.md\n\n## Milestones\n- a\n- b\n- c\n- d\n- e\n- f\n' > "$journal" + run "$BATS_TEST_DIRNAME/../extras/spine-promotion/journal-spine-promotion-nudge.sh" + [ "$status" -eq 0 ] +} + +@test "a title-cased DETAILED journal still counts its milestones" { + journal="$SESSION_JOURNAL_DIR/2026-01-02-0304-cased.md" + printf '**Status:** ACTIVE\n\n## Live Plan Pointer\nnone\n\n## Milestones\n- a\n- b\n- c\n- d\n- e\n- f\n' > "$journal" + run "$BATS_TEST_DIRNAME/../extras/spine-promotion/journal-spine-promotion-nudge.sh" + [ "$status" -eq 2 ] + [[ "$output" == *"6 milestones"* ]] +} From edc85b0258294ff0e19eb21ea2e27f3e55ecad52 Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Thu, 27 Aug 2026 03:36:17 +0530 Subject: [PATCH 06/14] docs: correct two stale claims in this session's own journal A rewritten commit hash and a superseded test count -- both cited in the journal this branch added as its dogfood, and both exactly the rot the new specification section warns about. The hash rotted because amending a commit invalidates any hash recorded before the amend, which no commit can avoid for itself; the final commit is now cited by subject instead. --- sessions/2026-08-27-0130-operate-the-discipline.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sessions/2026-08-27-0130-operate-the-discipline.md b/sessions/2026-08-27-0130-operate-the-discipline.md index 52e3eb7..234da8b 100644 --- a/sessions/2026-08-27-0130-operate-the-discipline.md +++ b/sessions/2026-08-27-0130-operate-the-discipline.md @@ -23,7 +23,7 @@ none - 2026-08-27 02:45 — Installer made safe to run twice and safe to fail; `doctor.sh` added. Suite 70 → 89. - 2026-08-27 02:54 — Docs, spec 1.1, CHANGELOG, SECURITY.md, and CI hardening. Suite 89 → 95. - 2026-08-27 03:20 — Adversarial re-review of the branch. It earned its keep: the secrets guard I "fixed" had in fact stopped catching `PASSWORD="..."`, backticked, and punctuated values, and a plain installer re-run silently relocated the journal directory, orphaning real journals. -- 2026-08-27 03:30 — All six confirmed regressions corrected, each with a regression test. Suite 95 → 121. +- 2026-08-27 03:30 — All six confirmed regressions corrected, each with a regression test. Suite 95 → 123. ## Decisions and constraints @@ -37,7 +37,7 @@ none Prefer re-running these over trusting the numbers: -- `bats tests` — 121 checks, currently all passing. `tests/docs.bats` fails deliberately if this count drifts from `grep -c '^@test' tests/*.bats`. +- `bats tests` — 123 checks, currently all passing. `tests/docs.bats` fails deliberately if this count drifts from `grep -c '^@test' tests/*.bats`. - `shellcheck hooks/*.sh extras/spine-promotion/*.sh install.sh doctor.sh` — clean. - `./doctor.sh .` — passes against this repository's own installation. - Fresh-clone selection: clone a repo with journals dated January, May and August; the pointer must name the August journal. Was naming January. @@ -52,7 +52,7 @@ Prefer re-running these over trusting the numbers: - `a9e4e2a` fix: make installation safe to run twice and safe to fail, and add a doctor - `afb51d6` docs: state the selection rule, hold the prose under test, and dogfood the discipline - `5fc948c` docs: teach ADOPT.md the doctor, the template, and the handoff to a team -- `2a1ddcd` fix: repair the regressions an adversarial review found in the 1.1.0 fixes +- fix: repair the regressions an adversarial review found in the 1.1.0 fixes — the branch tip; cited by subject because a commit cannot carry its own hash, and amending one invalidates any hash written before the amend ## Files touched @@ -68,7 +68,7 @@ Prefer re-running these over trusting the numbers: All twelve original defects are fixed, and so are the six regressions the adversarial review found in those fixes. Both rounds have regression tests; the -suite went 41 → 121. The project now dogfoods itself: `.claude/` is installed +suite went 41 → 123. The project now dogfoods itself: `.claude/` is installed here, so CI's `./doctor.sh .` step is a real check rather than a formality. The two changes flagged as riskiest before the review were exactly the two that @@ -97,3 +97,4 @@ moving `main`. - 2026-08-27 01:30 — journal created - 2026-08-27 02:54 — recorded hook, installer, doctor, docs, and CI work - 2026-08-27 03:30 — recorded the adversarial review and the six regressions it caught +- 2026-08-27 03:45 — corrected two stale claims in this journal (a rewritten commit hash, a superseded test count); both were the drift the specification warns about, found by re-reading rather than by a tool From c91ec4cfecd02e59a3ee8f33bea5c15b262d3cbf Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Wed, 21 Oct 2026 22:49:34 +0530 Subject: [PATCH 07/14] feat: bind journals to the session that writes them Replace the v1 pointer and nudge shell hooks with one stdlib Python 3 program (hooks/session-journal.py) that handles SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop and SessionEnd and carries a continuity CLI. A per-session lease records the journal a session first writes. Start names that journal, or ranked open candidates with IN USE marked; Stop nudges only when this turn landed work and did not touch this session's own journal, at most once per turn. Writes into a journal another live session owns, or into a linked worktree's copy, are denied, as are credential-shaped journal writes (values never echoed). Covers /clear, forks, lead/worker roles with a worker ledger, a size limit with Continues:, Codex turn_id/apply_patch input, and sweep --by-git. The v1 shell hooks and their bats suites are retired; the scenario suite in tests/test_journal.py replaces them. --- .gitignore | 1 + VERSION | 2 +- hooks/session-journal-nudge.sh | 303 ------ hooks/session-journal-pointer.sh | 154 --- hooks/session-journal.py | 1546 ++++++++++++++++++++++++++++++ tests/nudge.bats | 224 ----- tests/pointer.bats | 125 --- tests/secrets-guard.bats | 155 --- tests/test_journal.py | 938 ++++++++++++++++++ 9 files changed, 2486 insertions(+), 962 deletions(-) delete mode 100755 hooks/session-journal-nudge.sh delete mode 100755 hooks/session-journal-pointer.sh create mode 100644 hooks/session-journal.py delete mode 100644 tests/nudge.bats delete mode 100644 tests/pointer.bats delete mode 100644 tests/secrets-guard.bats create mode 100644 tests/test_journal.py diff --git a/.gitignore b/.gitignore index 1c95b5f..1c9ee05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ sessions/*.local.md .DS_Store +__pycache__/ diff --git a/VERSION b/VERSION index 9084fa2..227cea2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +2.0.0 diff --git a/hooks/session-journal-nudge.sh b/hooks/session-journal-nudge.sh deleted file mode 100755 index 76410d4..0000000 --- a/hooks/session-journal-nudge.sh +++ /dev/null @@ -1,303 +0,0 @@ -#!/bin/bash -# Stop hook: enforce a fresh session journal and reject secret-looking content. -# -# Claude Code and Codex pass JSON on stdin. stop_hook_active prevents a second -# freshness block, while the secrets guard always runs. Some runners provide no -# stdin; for those only, a short-lived marker supplies one-shot loop protection. -# -# session-journal — https://github.com/dctmfoo/session-journal (MIT) - -set -e - -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" -SESSIONS_DIR="${SESSION_JOURNAL_DIR:-$PROJECT_DIR/sessions}" -case "$SESSIONS_DIR" in - /*) ;; - *) SESSIONS_DIR="$PROJECT_DIR/$SESSIONS_DIR" ;; -esac - -FRESH_SECS="${JOURNAL_FRESH_SECS:-300}" -case "$FRESH_SECS" in - ''|*[!0-9]*) - echo "SESSION-JOURNAL: JOURNAL_FRESH_SECS='$FRESH_SECS' is not a whole number of seconds; using 300." >&2 - FRESH_SECS=300 - ;; -esac - -# One-shot markers exist to break a stop cascade within a single session. Without -# a TTL the marker outlives the session that armed it and silently swallows the -# next session's first nudge, so anything older than this is treated as stale. -MARKER_TTL="${JOURNAL_STOP_MARKER_TTL_SECS:-60}" -case "$MARKER_TTL" in - ''|*[!0-9]*) MARKER_TTL=60 ;; -esac - -# Read past the first line of the stdin payload. A single `read` stops at the -# first newline, so a runner that pretty-prints its hook JSON would hand us "{", -# which defeats the stop_hook_active check AND suppresses the marker fallback -# below (because the payload is non-empty), leaving no loop protection at all. -# -# Only the head of the payload is needed -- `stop_hook_active` is a top-level -# scalar -- so the drain is bounded three ways: a line cap, a total deadline, and -# a per-read timeout. All three matter. Without the cap a megabyte of transcript -# grows the accumulator quadratically; without the deadline a slow writer extends -# the hook indefinitely, because a per-read timeout only bounds one read; without -# the per-read timeout a runner that never closes stdin hangs the hook outright. -INPUT="" -STDIN_LINE="" -STDIN_LINES=0 -STDIN_DEADLINE=$(( $(date +%s) + 5 )) -while [ "$STDIN_LINES" -lt 200 ] && [ "$(date +%s)" -lt "$STDIN_DEADLINE" ]; do - IFS= read -r -t 2 STDIN_LINE 2>/dev/null || break - INPUT="$INPUT$STDIN_LINE -" - STDIN_LINES=$((STDIN_LINES + 1)) - STDIN_LINE="" -done -# bash 3.2's `read -t` discards a partial line on timeout, but on EOF without a -# trailing newline it still populates the variable; keep that last fragment. -[ -z "$STDIN_LINE" ] || INPUT="$INPUT$STDIN_LINE" - -STOP_ACTIVE=false -if [ -n "$INPUT" ]; then - if command -v python3 >/dev/null 2>&1; then - STOP_ACTIVE=$(printf '%s' "$INPUT" | python3 -c 'import json,sys -try: print("true" if json.load(sys.stdin).get("stop_hook_active") is True else "false") -except Exception: print("false")' 2>/dev/null || printf false) - elif command -v jq >/dev/null 2>&1; then - STOP_ACTIVE=$(printf '%s' "$INPUT" | jq -r 'if .stop_hook_active == true then "true" else "false" end' 2>/dev/null || printf false) - elif printf '%s' "$INPUT" | tr '\n' ' ' | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then - STOP_ACTIVE=true - fi -fi - -MARKER="" -if [ -z "$INPUT" ]; then - PROJECT_KEY=$(printf '%s' "$PROJECT_DIR" | cksum | awk '{print $1}') - MARKER="${TMPDIR:-/tmp}/session-journal-nudge-$PROJECT_KEY.d" - if [ -d "$MARKER" ]; then - MARKER_MTIME=$(stat -f %m "$MARKER" 2>/dev/null || stat -c %Y "$MARKER" 2>/dev/null || printf '') - case "$MARKER_MTIME" in ''|*[!0-9]*) MARKER_MTIME="" ;; esac - rmdir "$MARKER" 2>/dev/null || true - if [ -n "$MARKER_MTIME" ] && [ "$(( $(date +%s) - MARKER_MTIME ))" -le "$MARKER_TTL" ]; then - STOP_ACTIVE=true - fi - fi -fi - -# A Stop hook is not an installer: it must not create the sessions directory as -# a side effect. The glob below handles a nonexistent directory correctly. - -# Newest journal: mtime leads, filename settles ties. mtime alone is wrong -# whenever several journals share a timestamp -- a fresh `git clone`, a branch -# checkout, an unpacked archive -- because bash 3.2 compares `-nt` at -# whole-second resolution and the tie keeps the alphabetically OLDEST file. -LATEST="" -LATEST_BASE="" -for candidate in "$SESSIONS_DIR"/*.md; do - [ -e "$candidate" ] || continue - base=$(basename "$candidate") - case "$base" in README.md|TEMPLATE.md) continue ;; esac - if [ -z "$LATEST" ]; then - LATEST=$candidate - LATEST_BASE=$base - elif [ "$candidate" -nt "$LATEST" ]; then - LATEST=$candidate - LATEST_BASE=$base - elif [ ! "$LATEST" -nt "$candidate" ] && [ "$base" \> "$LATEST_BASE" ]; then - LATEST=$candidate - LATEST_BASE=$base - fi -done - -# The allowlist applies only to the line carrying the marker. Values are never -# echoed back: diagnostics identify line numbers so a hook cannot leak a secret. -if [ -n "$LATEST" ]; then - CANDIDATES=$(grep -nEv '' "$LATEST" 2>/dev/null || true) - - # Credential shapes that carry their own prefix are unambiguous: a JWT, a - # bearer value, an AWS key id, a GitHub or Slack token, a private-key header. - NAMED=$(printf '%s\n' "$CANDIDATES" \ - | grep -Ei 'e[y]J[A-Za-z0-9_-]{20,}|Bearer[[:space:]]+[A-Za-z0-9._-]{16,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{16,}|-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----' || true) - - # `key: value` assignments need the VALUE inspected, not just the key name. - # The old pattern flagged any 6+ non-space token after a credential key, which - # blocked `password: ` and `token: stored-in-ci-secrets` -- the exact - # remediations this hook's own error message prescribes, producing a block -> - # redact -> block loop the runtime cannot break. - # - # Only the placeholder test may exempt a value. Quotes, backticks and markdown - # emphasis are stripped first rather than treated as evidence of a placeholder: - # a credential pasted from a .env file, a YAML file or a code fence is the - # normal case, not the exotic one, and exempting decorated values would have - # let PASSWORD="..." and `api_key: "AIza..."` straight through. There is - # deliberately no character whitelist either -- punctuation makes a value more - # password-like, not less. - # - # Every credential key on the line is examined, not just the first. A line that - # redacts its first value and forgets its second is exactly what a partial - # remediation looks like, and it must not exempt the rest of the line. - ASSIGNED=$(awk ' - function unwrap(v) { - sub(/^[\"'"'"'`([*]+/, "", v) - sub(/[\"'"'"'`)\]*,;.]+$/, "", v) - sub(/[-_]+$/, "", v) - return v - } - # A value written with an ellipsis is an elided example, not a credential: - # `api_key: sk-live-...` is how documentation and this project own tests - # refer to a secret without containing one. - function is_elided(v) { - return (v ~ /\.\.\./ || v ~ /…/) - } - function is_placeholder(v, head) { - if (v ~ /^[<${]/) return 1 - head = v - sub(/[-_.:\/].*$/, "", head) - return (head ~ /^(redacted|redact|redacts|placeholder|example|sample|dummy|fake|test|none|null|nil|unset|empty|blank|omitted|elided|scrubbed|masked|hidden|removed|rotated|expired|revoked|sanitized|sanitised|todo|tbd|changeme|see|stored|store|kept|held|managed|provided|supplied|injected|configured|set|use|using|from|in|via|your|my|the|not|no|never|only|env|vault|keychain|n\/a|n)$/) - } - { - if ($0 ~ //) next - line = $0 - rest = line - while (match(tolower(rest), /(client[_-]?secret|api[_-]?key|secret[_-]?key|access[_-]?key|password|passphrase|token)[[:space:]]*[:=][[:space:]]*/) > 0) { - value = substr(rest, RSTART + RLENGTH) - rest = value - sub(/[[:space:]].*$/, "", value) - if (is_elided(value)) continue - value = unwrap(value) - if (length(value) >= 8 && !is_placeholder(tolower(value))) { - print NR ":" line - break - } - } - } - ' "$LATEST" 2>/dev/null || true) - - # A bare long hex string is only suspicious away from digest context. SHA-256 - # artifact receipts, git revisions, and fingerprints are this program's - # evidence, not secrets, and must survive verbatim to stay verifiable. - # - # Context is read over a three-line window, NOT the hash's own line. Prose - # wraps, and a 64-character digest routinely lands on a line of its own with - # "hashes to" or "SHA-256" ending the line above; a single-line check sees a - # bare hex string and cries wolf. - # - # Every non-alphanumeric run collapses to a single space before matching, so - # the keywords compare as whole words. Substring matching let "Avoid", - # "Android" and "paranoid" satisfy `oid`, silently disabling hex detection for - # their neighbouring lines. Inflections and compounds are spelled out instead, - # because "sha256sum", "hashing" and "committed" are genuine digest context - # that a bare whole-word list would have started false-positiving on. - BARE_HEX=$(awk ' - { lines[NR] = $0 } - END { - for (i = 1; i <= NR; i++) { - if (lines[i] ~ //) continue - ctx = " " tolower((i > 1 ? lines[i-1] : "") " " lines[i] " " (i < NR ? lines[i+1] : "")) " " - gsub(/[^a-z0-9]+/, " ", ctx) - if (ctx ~ / (sha[0-9]*(sum)?|hash(es|ed|ing)?|digest(s|ed)?|checksum(s|ed|med|ming)?|fingerprint(s|ed)?|commit(s|ted|ting)?|revision(s)?|blob(s)?|oid(s)?|etag(s)?|sri|integrity) /) continue - print i ":" lines[i] - } - }' "$LATEST" 2>/dev/null | grep -Ei '[0-9a-f]{40,}' || true) - - LEAK_LINES=$(printf '%s\n%s\n%s\n' "$NAMED" "$ASSIGNED" "$BARE_HEX" \ - | grep -E '^[0-9]+:' | cut -d: -f1 | sort -n -u | head -n 3 | paste -sd, - || true) - if [ -n "$LEAK_LINES" ]; then - echo "SESSION-JOURNAL-SECRETS-GUARD: secret-looking content found in $LATEST at line(s) $LEAK_LINES (first three shown). Replace the value with a key name, an env-var reference, or a placeholder such as . If a human has confirmed the line carries no credential value, append to that line to exempt it." >&2 - exit 2 - fi -fi - -if [ "$STOP_ACTIVE" = "true" ]; then - printf '{}\n' - exit 0 -fi - -# A trivial session -- a typo fix, a dependency bump -- does not need a journal. -# Without a documented escape the only ways out are to burn stop_hook_active or -# to write a journal about nothing, and both teach the agent to route around the -# discipline. The secrets guard above deliberately still runs. -case "${SESSION_JOURNAL_SKIP:-}" in - ''|0|false|no) ;; - *) printf '{}\n'; exit 0 ;; -esac - -block_once() { - [ -z "$MARKER" ] || mkdir "$MARKER" 2>/dev/null || true - echo "$1" >&2 - exit 2 -} - -if [ -z "$LATEST" ]; then - block_once "SESSION-JOURNAL-REMINDER: No session journal exists. Create YYYY-MM-DD-HHMM-.md from the local template before finishing resumable work. If this session needs no journal, set SESSION_JOURNAL_SKIP=1." -fi - -MTIME=$(stat -f %m "$LATEST" 2>/dev/null || stat -c %Y "$LATEST" 2>/dev/null || printf '0') -case "$MTIME" in ''|*[!0-9]*) MTIME=0 ;; esac -NOW=$(date +%s) -AGE=$((NOW - MTIME)) - -PLAN_LINE=$(awk ' - tolower($0) ~ /^##[[:space:]]+live plan pointer[[:space:]]*$/ { inside=1; next } - inside && /^## / { exit } - inside && NF { print; exit } -' "$LATEST" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' || true) -PLAN_LOWER=$(printf '%s' "$PLAN_LINE" | tr '[:upper:]' '[:lower:]') - -mode_reminder() { - case "$PLAN_LOWER" in - ""|none|\*) - block_once "SESSION-JOURNAL-REMINDER (DETAILED mode): $1 Update milestones, files, current state, next step, and Last updated; promote recurring work into a durable plan and switch to THIN." - ;; - *) - block_once "SESSION-JOURNAL-REMINDER (THIN mode — pointer: $PLAN_LINE): $1 Add one-line evidence and refresh current state, next step, and Last updated without duplicating the governing document." - ;; - esac -} - -# Freshness by mtime alone measures a property the agent can set directly: a -# bare `touch` satisfies the gate with zero characters changed. When git can -# tell us whether the journal actually moved, require content, not a timestamp. -journal_records_work() { - local journal=$1 dirty journal_dirty journal_rel - case "${JOURNAL_REQUIRE_CONTENT_CHANGE:-1}" in - 0|false|no) return 0 ;; - esac - command -v git >/dev/null 2>&1 || return 0 - git -C "$PROJECT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 - # Local-only journals are invisible to `git status`; fall back to mtime. - if git -C "$PROJECT_DIR" check-ignore -q "$journal" 2>/dev/null; then return 0; fi - - # Untracked files are excluded deliberately. A stray .DS_Store or a local - # scratch file is not work the journal owes an entry for, and counting it made - # the gate fire on every Stop in an otherwise clean repository. - dirty=$(git -C "$PROJECT_DIR" status --porcelain --untracked-files=no 2>/dev/null) || return 0 - # Nothing tracked has changed: there is no work for the journal to record. - [ -n "$dirty" ] || return 0 - - # The journal is being edited right now. - journal_dirty=$(git -C "$PROJECT_DIR" status --porcelain -- "$journal" 2>/dev/null) || return 0 - [ -z "$journal_dirty" ] || return 0 - - # The journal is clean, which can mean "nobody touched it" or "it was just - # committed alongside the work". Only the first deserves a nudge, so treat a - # journal carried by the most recent commit as having recorded its session. - journal_rel=$(git -C "$PROJECT_DIR" ls-files --full-name -- "$journal" 2>/dev/null | head -n 1) || return 0 - if [ -n "$journal_rel" ] \ - && git -C "$PROJECT_DIR" diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null \ - | grep -qxF "$journal_rel"; then - return 0 - fi - return 1 -} - -if [ "$AGE" -lt "$FRESH_SECS" ]; then - if journal_records_work "$LATEST"; then - printf '{}\n' - exit 0 - fi - mode_reminder "the newest journal does not record the tracked changes still outstanding in this repository: its timestamp is current but its content is not." -fi - -mode_reminder "the newest journal is ${AGE}s old." diff --git a/hooks/session-journal-pointer.sh b/hooks/session-journal-pointer.sh deleted file mode 100755 index 01ead75..0000000 --- a/hooks/session-journal-pointer.sh +++ /dev/null @@ -1,154 +0,0 @@ -#!/bin/bash -# SessionStart hook: inject a compact pointer to the live session journal. -# Compatible with bash 3.2 and with Claude Code and Codex command hooks. -# -# session-journal — https://github.com/dctmfoo/session-journal (MIT) - -set -e - -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" -SESSIONS_DIR="${SESSION_JOURNAL_DIR:-$PROJECT_DIR/sessions}" -case "$SESSIONS_DIR" in - /*) ;; - *) SESSIONS_DIR="$PROJECT_DIR/$SESSIONS_DIR" ;; -esac - -# A SessionStart hook is a read path. It must not create directories: a -# read-only checkout or a mistargeted PROJECT_DIR would turn a missing-context -# event into a nonzero exit and lose the injection entirely. The glob below -# handles a nonexistent directory correctly on its own. - -journal_status() { - # First word of the mandatory status line, upper-cased. Empty when absent. - sed -n 's/^[[:space:]]*\*\*Status:\*\*[[:space:]]*//p' "$1" 2>/dev/null \ - | head -n 1 \ - | awk '{print toupper($1)}' \ - | tr -cd '[:upper:]' -} - -# "Newest" is decided by modification time, with the filename as the tie-break. -# mtime alone is wrong whenever a filesystem hands several journals the same -# timestamp -- a fresh `git clone`, a branch checkout, an unpacked archive, a -# restored backup -- because bash 3.2 compares `-nt` at whole-second resolution -# and the tie silently keeps whichever name the glob yielded first, i.e. the -# OLDEST journal. The filename is the session-start time by specification and -# sorts lexicographically, so it settles ties correctly. mtime still leads, so a -# journal appended to across days stays live even though its name is older. -LATEST="" -LATEST_BASE="" -for candidate in "$SESSIONS_DIR"/*.md; do - [ -e "$candidate" ] || continue - base=$(basename "$candidate") - case "$base" in README.md|TEMPLATE.md) continue ;; esac - if [ -z "$LATEST" ]; then - LATEST=$candidate - LATEST_BASE=$base - elif [ "$candidate" -nt "$LATEST" ]; then - LATEST=$candidate - LATEST_BASE=$base - elif [ ! "$LATEST" -nt "$candidate" ] && [ "$base" \> "$LATEST_BASE" ]; then - LATEST=$candidate - LATEST_BASE=$base - fi -done - -# A DONE journal is a finished session, not the live frontier. Prefer the newest -# journal still open for work; fall back to reporting the closed one honestly -# rather than replaying its "no work remains" next step to a fresh agent. -LIVE="" -LIVE_BASE="" -if [ -n "$LATEST" ] && [ "$(journal_status "$LATEST")" = "DONE" ]; then - for candidate in "$SESSIONS_DIR"/*.md; do - [ -e "$candidate" ] || continue - base=$(basename "$candidate") - case "$base" in README.md|TEMPLATE.md) continue ;; esac - case "$(journal_status "$candidate")" in - ACTIVE|BLOCKED) ;; - *) continue ;; - esac - if [ -z "$LIVE" ]; then - LIVE=$candidate - LIVE_BASE=$base - elif [ "$candidate" -nt "$LIVE" ]; then - LIVE=$candidate - LIVE_BASE=$base - elif [ ! "$LIVE" -nt "$candidate" ] && [ "$base" \> "$LIVE_BASE" ]; then - LIVE=$candidate - LIVE_BASE=$base - fi - done -fi - -relative_path() { - local resolved="${1#"$PROJECT_DIR"/}" - [ "$resolved" = "$1" ] && resolved="$1" - printf '%s' "$resolved" -} - -if [ -z "$LATEST" ]; then - REL_SESSIONS=$(relative_path "$SESSIONS_DIR") - MESSAGE="No session journal exists in $REL_SESSIONS/. Once this session's intent is clear, create YYYY-MM-DD-HHMM-.md from $REL_SESSIONS/TEMPLATE.md and follow $REL_SESSIONS/README.md." -else - CLOSED="" - if [ "$(journal_status "$LATEST")" = "DONE" ]; then - if [ -n "$LIVE" ]; then - CLOSED=$(relative_path "$LATEST") - LATEST=$LIVE - else - CLOSED="only" - fi - fi - - RELATIVE=$(relative_path "$LATEST") - STATUS_LINE=$(grep -m 1 '^[[:space:]]*\*\*Status:\*\*' "$LATEST" 2>/dev/null || true) - [ -n "$STATUS_LINE" ] || STATUS_LINE="**Status:** Status not recorded" - - # Heading matching is case-insensitive: a journal written from memory rather - # than from the template routinely title-cases the heading, and an exact match - # silently drops the one section this hook exists to deliver. - NEXT_STEP=$(awk ' - tolower($0) ~ /^##[[:space:]]+next step for a fresh agent[[:space:]]*$/ { inside=1; next } - inside && /^## / { exit } - inside { print } - ' "$LATEST" 2>/dev/null | sed '/^[[:space:]]*$/d' || true) - if [ -z "$NEXT_STEP" ]; then - NEXT_STEP="Next step not recorded. This journal has no \"## Next step for a fresh agent\" section; read it in full, then add that section before ending the turn." - fi - - if [ "$CLOSED" = "only" ]; then - MESSAGE="Most recent session journal: $RELATIVE — its status is DONE, so it is a finished session and not the live frontier. Read it for background, treat this session as new work, and create a new journal once the intent is clear. -$STATUS_LINE -Last recorded next step (from the closed journal): -$NEXT_STEP" - elif [ -n "$CLOSED" ]; then - MESSAGE="Live session journal: $RELATIVE. Read it before acting. Continue the same work by appending to it; start unrelated work in a new journal. A more recent journal ($CLOSED) is already DONE and is background only. -$STATUS_LINE -Next step for a fresh agent: -$NEXT_STEP" - else - MESSAGE="Most recent session journal: $RELATIVE. Read it before acting. Continue the same work by appending to it; start unrelated work in a new journal. -$STATUS_LINE -Next step for a fresh agent: -$NEXT_STEP" - fi -fi - -json_string() { - if command -v python3 >/dev/null 2>&1; then - python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' - elif command -v python >/dev/null 2>&1; then - python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' - else - # Minimal documented fallback: preserve valid JSON by escaping the control - # characters a journal can realistically contain, then flattening newlines. - sed 's/\\/\\\\/g; s/"/\\"/g' \ - | tr '\t' ' ' \ - | tr -d '\r' \ - | tr '\n' ' ' \ - | sed 's/^/"/; s/$/"/' - fi -} - -ESCAPED=$(printf '%s' "$MESSAGE" | json_string) -printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "$ESCAPED" -exit 0 diff --git a/hooks/session-journal.py b/hooks/session-journal.py new file mode 100644 index 0000000..0f71e97 --- /dev/null +++ b/hooks/session-journal.py @@ -0,0 +1,1546 @@ +#!/usr/bin/env python3 +"""Session-bound journals for coding agents (Claude Code and Codex). + +session-journal 2.0.0 - https://github.com/dctmfoo/session-journal (MIT) + +One stdlib-only file, two faces: + + hook hook entry point; reads the hook JSON on stdin. Events: SessionStart, UserPromptSubmit, + PreToolUse, PostToolUse, Stop, SessionEnd + [args] agent / operator CLI: candidates, find, reconstruct, list, health, scan, sweep, whoami, bound, + bind, release, role, skip, version + +A journal belongs to the session(s) that write it. Each session keeps a lease (/leases/.json) that +records the journal it is bound to, its worktree, role and liveness. The first journal write binds the lease. Every +question v1 answered with "which journal is newest?" is answered with "which journal is THIS session bound to?": + + - parallel sessions no longer pick up, or get nudged about, each other's journals; + - a journal another live session is bound to is IN USE: writes into it are denied, and the agent is told to start + its own journal with a '**Continues:**' line instead; + - Stop nudges only when this turn landed work (an edit or a commit) and did not touch this session's own journal, + and at most once per turn. + +Paths resolve against the MAIN checkout of the session's project, never a linked worktree, and never the directory +an agent has `cd`-ed into. A project without a journal directory is left alone: the hooks never create it. + +Environment (all optional): + SESSION_JOURNAL_DIR journal directory, relative to the main checkout (default: sessions) + SESSION_JOURNAL_STATE_DIR lease/trace directory (default:
/.claude/state/session-journal, self-gitignored) + SESSION_JOURNAL_SKIP=1 never nudge this session (the secrets check still runs) + SESSION_JOURNAL_DISABLE=1 every hook does nothing + JOURNAL_ROLE lead (default) or worker; a worker is never nudged and its journal writes are denied + JOURNAL_WORKER_PROMPT regex; a first prompt that matches makes the session a soft worker (never nudged) + JOURNAL_LANDING_BASH regex; extra Bash commands that count as landed work (default: git commit / git push) + JOURNAL_SECRETS deny (default), warn, or off + JOURNAL_LIVE_MIN (45) JOURNAL_LIVE_MAX_H (24) JOURNAL_NUDGE_MIN (20) JOURNAL_MAX_KB (40) + JOURNAL_PAUSE_ON_END 1 (default) marks an ACTIVE journal PAUSED when its session ends; 0 leaves it + JOURNAL_TRANSCRIPTS_DIR where `reconstruct` looks for transcripts (default: ~/.claude/projects) +""" +from __future__ import annotations + +import collections +import contextlib +import datetime as dt +import glob +import json +import math +import os +import re +import subprocess +import sys +import time + +try: + import fcntl +except ImportError: # not POSIX: run without the lock + fcntl = None + +VERSION = '2.0.0' +SKIP_OK = '' +NAME = re.compile(r'^(\d{4}-\d\d-\d\d-(?:\d{4}-)?[A-Za-z0-9._-]+\.md)$') # HHMM is optional: older journals lack it +CLOSED = {'DONE', 'COMPLETE', 'COMPLETED', 'DELIVERED', 'CLOSED', 'SUPERSEDED', 'ABANDONED', '?'} # anything else is open +LANDING_TOOLS = {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'apply_patch'} +COMMIT_BASH = re.compile(r'\bgit\s+(?:-C\s+\S+\s+)?(?:commit|push)\b') +LEAD_OVERRIDE = re.compile(r'JOURNAL_ROLE=lead|keep (?:your own|a) (?:session )?journal', re.I) +PATCH_PATH = re.compile(r'^\*\*\* (?:Add|Update|Delete) File: (.+?)\s*$|^\*\*\* Move to: (.+?)\s*$', re.M) +STOPWORDS = set('the a an and or to of in on for with this that is are be it we you me my our at by from as do does did ' + 'not no yes now then so if please can could would should will just also need want lets let make use ' + 'using check look see read write update add fix run new session journal'.split()) + +# ---------------------------------------------------------------- secrets +# Credential shapes. A rule in VALUE_RULES matched a key NAME; its captured value must also look like a literal +# credential (looks_secret) before it counts, so `password: `, `token: $API_TOKEN` and +# `client_secret: stored-in-ci-secrets` -- the redactions this check asks for -- never block their own fix. +SECRET_KEY = (r'[A-Za-z0-9_.-]*(?:password|passwd|passphrase|pwd|secret|secretkey|client_secret|api_?key|access_?key|' + r'account_?key|private_?key|auth_?token|access_?token|refresh_?token|token)[A-Za-z0-9_.-]*') +PLACEHOLDER = r'(?!<|\$|\{|%|\*{3}|REDACTED|redacted|xxx|XXX|\.\.\.|…|None\b|null\b|""|\'\')' +SECRETS = [ + ('azure-storage-key', re.compile(r'AccountKey=[A-Za-z0-9+/=]{20,}|\b[A-Za-z0-9+/]{86}==')), + ('private-key', re.compile(r'-----BEGIN [A-Z ]*PRIVATE KEY-----')), + ('aws-key', re.compile(r'\bAKIA[0-9A-Z]{16}\b')), + ('github-token', re.compile(r'\bgh[pousr]_[A-Za-z0-9]{30,}|\bgithub_pat_[A-Za-z0-9_]{40,}')), + ('api-key', re.compile(r'\b(?:sk|rk)-[A-Za-z0-9_-]{24,}')), + ('slack-token', re.compile(r'\bxox[baprs]-[A-Za-z0-9-]{10,}')), + ('jwt', re.compile(r'\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}')), + ('bearer-token', re.compile(r'\bBearer\s+' + PLACEHOLDER + r'([A-Za-z0-9._~+/-]{20,}=*)')), + ('sas-token', re.compile(r'[?&]sig=[A-Za-z0-9%+/=]{20,}')), + ('url-credential', re.compile(r'\b[a-z][a-z0-9+.-]*://[^\s/:@<>{}$]+:' + PLACEHOLDER + r'([^\s/@<>]{6,})@')), + # The key NAME may embed the secret word (dbPassword:, SERVICE_API_KEY=), not only equal it. + ('secret-assignment', re.compile(r'(?i)(?)\s*[`"\']?' + + PLACEHOLDER + r'([^\s`"\'|,;)\]}]{6,})')), + ('password-prose', re.compile( + r'(?i)\b(?:password|passwd|passphrase|pwd|secret)\b(?:\s+(?:for|of)\s+[\w .@-]{1,40})?\s+(?:is|was|=|:)\s+' + r'(?:now\s+)?[`"\']?(?!<|\$|\{|\*{3}|REDACTED|xxx|in\b|stored\b|kept\b|available\b|the\b|a\b|an\b|set\b|' + r'rotated\b|not\b|still\b|also\b|correct\b|wrong\b)([^\s`"\'|]{6,})')), + ('password-table-cell', re.compile(r'(?i)\|\s*`?[A-Za-z0-9_.-]*(?:password|passwd|pwd|secret)[A-Za-z0-9_.-]*`?\s*\|' + r'\s*`?([^|\n`]{6,}?)`?\s*\|')), + ('cli-secret-flag', re.compile(r'--(?:password|passwd|client-secret|secret|token)(?:=|\s+)[`"\']?' + PLACEHOLDER + + r'([^\s`"\']{6,})')), +] +VALUE_RULES = {'bearer-token', 'secret-assignment', 'password-prose', 'password-table-cell', 'cli-secret-flag', + 'url-credential'} +NOT_SECRET_WORDS = {'mandatory', 'required', 'optional', 'hardcoded', 'default', 'unchanged', 'invalid', 'expired', + 'missing', 'rotated', 'redacted', 'placeholder', 'changeme', 'checking', 'enabled', 'disabled', + 'true', 'false'} + + +def looks_secret(value): + """A literal credential, not an identifier, path, URL, variable reference or English word.""" + v = re.sub(r'^[`"\'(*_]+|[`"\'.,;:)(*_]+$', '', value) + if len(v) < 6 or v.lower() in NOT_SECRET_WORDS or v.endswith(('...', '…')) or re.search(r'\s', v): + return False + if re.match(r'(?i)(https?|s3|wasbs?|abfss?|jdbc|ldaps?|file)[:/]|[~.]?/', v) or \ + ('/' in v and not re.search(r'[A-Za-z0-9+/]{16,}={0,2}$', v)): + return False # URLs, paths, namespace/secret-name references + if re.fullmatch(r'[a-z0-9]+(?:[-_.][a-z0-9]+)+', v) and len(v) < 24: + return False # lower-case identifiers: app-secret, stored-in-ci-secrets + if re.fullmatch(r'[A-Za-z]+', v): + return False # one word or camelCase identifier + if re.fullmatch(r'[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+', v): + return False # ENV_VAR_NAME or a cipher name used as a reference + if re.fullmatch(r'[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)+', v): + return False # dotted property names: spring.datasource.password + has_digit, has_alpha = bool(re.search(r'\d', v)), bool(re.search(r'[A-Za-z]', v)) + return (has_digit and has_alpha) or bool(re.search(r'[!@#$%^&*+=?~]', v)) + + +def scan_secrets(text): + """[(line_number, rule)] for credential-shaped content. Values are never returned, so they are never echoed. + A line carrying is exempt (a human verified it).""" + hits = [] + for n, line in enumerate((text or '').splitlines(), 1): + if SKIP_OK in line: + continue + for name, rx in SECRETS: + found = False + for m in rx.finditer(line): + if name in VALUE_RULES and not looks_secret(m.group(m.lastindex or 0)): + continue + found = True + break + if found: + hits.append((n, name)) + break + return hits + + +def fmt_hits(hits): + return ', '.join(f'line {n} ({rule})' for n, rule in hits[:5]) + (' ...' if len(hits) > 5 else '') + + +# ---------------------------------------------------------------- paths / state + +def git(*args, cwd=None, timeout=5): + try: + r = subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True, timeout=timeout) + return r.stdout.strip() if r.returncode == 0 else '' + except Exception: + return '' + + +def pid_alive(pid): + try: + os.kill(int(pid), 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + except Exception: + return False + + +def env_pid(): + try: + return int(os.environ.get('CLAUDE_PID') or 0) or None + except ValueError: + return None + + +def env_float(name, default): + try: + return float(os.environ.get(name) or default) + except ValueError: + return float(default) + + +def now_local(): + return dt.datetime.now().astimezone() + + +def worktree_label(top, main, branch): + """Name of a linked worktree. Pooled worktrees are often checked out as //, so a basename that + equals the main checkout's says nothing; the branch says what the worktree is for.""" + name = os.path.basename(top.rstrip(os.sep)) + if name == os.path.basename(main.rstrip(os.sep)) and branch and branch != 'HEAD': + return branch + return name + + +class Ctx: + def __init__(self, cwd=None): + self.cwd = cwd or os.getcwd() + # The session root. A hook's cwd follows the agent's `cd`, so it must not decide which project's journals are + # used: Claude Code keeps $CLAUDE_PROJECT_DIR at the session's project root. Codex reports the session cwd. + self.root = os.environ.get('CLAUDE_PROJECT_DIR') or self.cwd + out = git('rev-parse', '--git-common-dir', '--show-toplevel', '--abbrev-ref', 'HEAD', cwd=self.root).splitlines() + common = out[0] if out else '' + self.common = os.path.normpath(os.path.join(self.root, common)) if common else '' + self.top = out[1] if len(out) > 1 else self.root + self.branch = out[2] if len(out) > 2 else '?' + self.main = os.path.dirname(self.common) if os.path.basename(self.common) == '.git' else self.top + self.worktree = 'main' if os.path.realpath(self.top) == os.path.realpath(self.main) else \ + worktree_label(self.top, self.main, self.branch) + jdir = os.environ.get('SESSION_JOURNAL_DIR') or 'sessions' + self.jdir = os.path.normpath(jdir if os.path.isabs(jdir) else os.path.join(self.main, jdir)) + self.jrel = os.path.relpath(self.jdir, self.main) if self.jdir.startswith(self.main + os.sep) else self.jdir + self.tail = os.path.basename(self.jdir) + sdir = os.environ.get('SESSION_JOURNAL_STATE_DIR') or os.path.join('.claude', 'state', 'session-journal') + self.sdir = sdir if os.path.isabs(sdir) else os.path.join(self.main, sdir) + self.enabled = os.path.isdir(self.jdir) # never create the journal directory + if self.enabled: + for d in ('leases', 'pending'): + os.makedirs(os.path.join(self.sdir, d), exist_ok=True) + ignore = os.path.join(self.sdir, '.gitignore') + if not os.path.exists(ignore): # state is per machine: keep it out of every commit + with open(ignore, 'w') as fh: + fh.write('*\n') + self.live_min = env_float('JOURNAL_LIVE_MIN', 45) + self.live_max_h = env_float('JOURNAL_LIVE_MAX_H', 24) + self.nudge_min = env_float('JOURNAL_NUDGE_MIN', 20) + self.max_bytes = int(env_float('JOURNAL_MAX_KB', 40) * 1024) + self.secrets = (os.environ.get('JOURNAL_SECRETS') or 'deny').strip().lower() + t = re.escape(self.tail) + r'/' + d = t + r'\d{4}-\d\d-\d\d-' + self.mention = re.compile(t + r'(\d{4}-\d\d-\d\d-(?:\d{4}-)?[A-Za-z0-9._-]+?\.md)') + self.path_mention = re.compile(r'((?()=]*?/|(?>?\s*["\']?[^\s"\'|;&<>]*' + d), + re.compile(r'\btee\s+(?:-a\s+)?["\']?[^\s"\'|;&<>]*' + d), + re.compile(r'\bsed\s+-i[^|;&]*' + d), + re.compile(r'\b(?:cp|mv|install|rsync)\s[^|;&]*' + d + r'[^\s"\'|;&]*\.md["\']?\s*(?:$|[|;&)])', re.M), + re.compile(r'open\(\s*[^)]*' + d + r'[^)]*,\s*[rb+]*["\'][wax]|Path\(\s*[^)]*' + d + + r'[^)]*\)\s*\.write_(?:text|bytes)|(?:writeFileSync|appendFileSync)\(\s*[^)]*' + d), + ] + extra = os.environ.get('JOURNAL_LANDING_BASH') + try: + self.landing_bash = re.compile(COMMIT_BASH.pattern + ('|' + extra if extra else '')) + except re.error: + self.landing_bash = COMMIT_BASH + here, main_real = os.path.realpath(__file__), os.path.realpath(self.main) + self.script = os.path.relpath(here, main_real) if here.startswith(main_real + os.sep) else here + + # locking ----------------------------------------------------------- + @contextlib.contextmanager + def lock(self, timeout=3.0): + """Serialise lease read-modify-write across hook processes (parallel tool calls, parallel sessions).""" + fd, got = None, False + try: + fd = os.open(os.path.join(self.sdir, '.lock'), os.O_CREAT | os.O_RDWR, 0o644) + t0 = time.time() + while fcntl: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + got = True + break + except OSError: + if time.time() - t0 > timeout: + break + time.sleep(0.01) + except OSError: + pass + try: + yield got + finally: + if fd is not None: + if got and fcntl: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + # leases ------------------------------------------------------------ + def lease_path(self, sid): + return os.path.join(self.sdir, 'leases', re.sub(r'[^A-Za-z0-9_-]', '', sid or 'nosid') + '.json') + + def lease(self, sid): + try: + with open(self.lease_path(sid)) as fh: + return json.load(fh) + except Exception: + return None + + def save_lease(self, lease, touch=True): + if touch: + lease['heartbeat'] = time.time() + p = self.lease_path(lease['sid']) + tmp = f'{p}.{os.getpid()}.tmp' + with open(tmp, 'w') as fh: + json.dump(lease, fh, indent=1) + os.replace(tmp, p) + return lease + + def leases(self): + out = [] + for p in glob.glob(os.path.join(self.sdir, 'leases', '*.json')): + try: + with open(p) as fh: + out.append(json.load(fh)) + except Exception: + pass + return out + + def is_live(self, lease, now=None): + if lease.get('ended'): + return False + age = (now or time.time()) - (lease.get('heartbeat') or 0) + if age < self.live_min * 60: + return True + # An idle chat or a long autonomous turn keeps its journal while its agent process lives (capped). + return bool(lease.get('pid')) and age < self.live_max_h * 3600 and pid_alive(lease['pid']) + + def owners(self, journal, me=None): + """Live sessions other than `me` bound to journal. If both are bound to it, the earlier binder owns it.""" + mine = me if me and me.get('journal') == journal else None + out = [] + for l in self.leases(): + if l.get('journal') != journal or (me and l.get('sid') == me.get('sid')) or not self.is_live(l): + continue + if mine and (l.get('bound_at') or 0) > (mine.get('bound_at') or 0): + continue + out.append(l) + return out + + def trace(self, **kw): + kw['t'] = now_local().isoformat(timespec='seconds') + p = os.path.join(self.sdir, 'events.jsonl') + try: + if os.path.exists(p) and os.path.getsize(p) > 2 * 1024 * 1024: + os.replace(p, p + '.1') + with open(p, 'a') as f: + f.write(json.dumps(kw) + '\n') + except OSError: + pass + + # journals ---------------------------------------------------------- + def journals(self): + try: + return sorted(os.path.join(self.jdir, n) for n in os.listdir(self.jdir) if NAME.match(n)) + except OSError: + return [] + + def jpath(self, name): + return os.path.join(self.jdir, name) + + def show(self, name): + return f'{self.jrel}/{name}' + + def journal_name(self, path): + """The journal name when path is //.md, else None.""" + if not path: + return None + path = str(path).replace('\\', '/') + head, base = os.path.split(path) + if NAME.match(base) and os.path.basename(head) == self.tail: + return base + return None + + def bash_journal_writes(self, cmd, cwd=None): + """Journal names a shell command certainly writes. With `cwd`, a name whose path visibly points at another + directory (an absolute or relative path that is not this journal directory), or that sits behind a variable + and does not exist here, is someone else's: test fixtures and other repositories often mention a + `sessions/` path inside a heredoc.""" + if not cmd or self.tail + '/' not in cmd: + return [] + if not any(r.search(cmd) for r in self.bash_writes): + return [] + names = set() + for m in self.path_mention.finditer(cmd): + prefix, name = m.group(1), m.group(2) + if cwd is None: # after the fact: a written journal exists + if not os.path.exists(self.jpath(name)): + continue + else: + if prefix.startswith(('$', '~')) or '$(' in prefix or '`' in prefix: + if not os.path.exists(self.jpath(name)): + continue + elif os.path.realpath(os.path.join(cwd, prefix, self.tail)) != os.path.realpath(self.jdir): + continue + names.add(name) + return sorted(names) + + +def header(text, key): + m = re.search(r'^\*\*' + re.escape(key) + r':\*\*\s*(.+)$', text, re.M) + return m.group(1).strip() if m else '' + + +def section(text, name): + m = re.search(r'^##\s+' + re.escape(name) + r'[^\n]*\n(.*?)(?=^## |\Z)', text, re.M | re.S | re.I) + return m.group(1).strip() if m else '' + + +def read(path): + try: + with open(path, errors='replace') as fh: + return fh.read() + except OSError: + return None + + +def card(ctx, name): + text = read(ctx.jpath(name)) + if text is None: + return None + m = re.search(r'^# (?:Session: )?(.+)$', text, re.M) + status = header(text, 'Status') + pointer = section(text, 'Live plan pointer') + return { + 'journal': name, 'title': (m.group(1) if m else name).strip(), + 'status': status.split()[0].upper().rstrip(':;,.') if status else '?', + 'label': re.sub(r'\s+', ' ', status or '?').strip()[:60], 'updated': header(text, 'Last updated'), + 'pointer': pointer.splitlines()[0].strip() if pointer else 'none', + 'next': re.sub(r'\s+', ' ', section(text, 'Next step for a fresh agent'))[:400], + 'sessions': header(text, 'Sessions'), 'worktree': header(text, 'Worktree'), 'bytes': len(text.encode()), + 'mtime': os.path.getmtime(ctx.jpath(name)), + } + + +def is_thin(pointer): + return (pointer or 'none').strip().lower().strip('`<>- ') not in ('none', '') + + +# ---------------------------------------------------------------- transcript (Claude Code JSONL) + +def read_transcript(path): + out = [] + if not path or not os.path.exists(path): + return out + with open(path, errors='replace') as fh: + for line in fh: + try: + d = json.loads(line) + except Exception: + continue + if isinstance(d, dict): + out.append(d) + return out + + +def is_human(d): + if d.get('type') != 'user' or d.get('isMeta') or d.get('isSidechain'): + return False + origin = d.get('origin') or {} + if isinstance(origin, dict) and origin.get('kind'): + return origin.get('kind') == 'human' + c = (d.get('message') or {}).get('content') + if isinstance(c, str): + return not c.startswith('<') and 'Stop hook feedback' not in c + if isinstance(c, list): + return any(isinstance(x, dict) and x.get('type') == 'text' and not x.get('text', '').startswith('<') for x in c) \ + and not any(isinstance(x, dict) and x.get('type') == 'tool_result' for x in c) + return False + + +def iso_epoch(ts): + try: + return dt.datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp() + except Exception: + return 0 + + +def patch_paths(patch, cwd): + out = [] + for m in PATCH_PATH.finditer(patch or ''): + p = (m.group(1) or m.group(2) or '').strip() + if p: + out.append(p if os.path.isabs(p) else os.path.join(cwd or '', p)) + return out + + +def patch_added_text(patch): + return '\n'.join(l[1:] for l in (patch or '').splitlines() if l.startswith('+') and not l.startswith('+++')) + + +def tool_targets(ctx, name, inp, cwd=None): + """(journal names written, other files written, bash command) for one tool call.""" + if name in ('Edit', 'Write', 'MultiEdit', 'NotebookEdit'): + p = inp.get('file_path') or inp.get('notebook_path') or '' + jn = ctx.journal_name(p) + return ([jn] if jn else []), ([] if jn else [p] if p else []), None + if name == 'apply_patch': + js, files = [], [] + for p in patch_paths(str(inp.get('command') or inp.get('patch') or inp.get('input') or ''), cwd): + jn = ctx.journal_name(p) + (js if jn else files).append(jn or p) + return js, files, None + if name == 'Bash': + cmd = str(inp.get('command', '')) + return ctx.bash_journal_writes(cmd, cwd), [], cmd + return [], [], None + + +def turn_facts(ctx, entries, floor=0): + """Facts about the current turn (since the last human message) and the session so far, from a Claude Code + transcript. `floor` (epoch) ignores journal writes before it: a fork's transcript carries the parent's history. + Other transcript formats yield empty facts; the hooks record their own facts for those.""" + start = 0 + failed = set() # tool calls a hook denied (or that failed) stay in the transcript but wrote nothing + for i, d in enumerate(entries): + if is_human(d): + start = i + elif d.get('type') == 'user': + c = (d.get('message') or {}).get('content') + for x in c if isinstance(c, list) else []: + if isinstance(x, dict) and x.get('type') == 'tool_result' and x.get('is_error'): + failed.add(x.get('tool_use_id')) + f = {'landed': False, 'committed': False, 'turn_journal_writes': set(), 'session_journal_writes': [], + 'turn_start_ts': entries[start].get('timestamp') if entries else None, 'first_prompt': None, + 'last_journal_write_ts': None, 'turn_files': [], 'turn_commits': [], 'last_text': ''} + for i, d in enumerate(entries): + if f['first_prompt'] is None and is_human(d): + c = (d.get('message') or {}).get('content') + f['first_prompt'] = c if isinstance(c, str) else ' '.join( + x.get('text', '') for x in c or [] if isinstance(x, dict)) + if d.get('type') != 'assistant' or d.get('isSidechain'): + continue + for c in (d.get('message') or {}).get('content') or []: + if isinstance(c, dict) and c.get('type') == 'text' and i >= start: + f['last_text'] = c.get('text', '') + if not isinstance(c, dict) or c.get('type') != 'tool_use' or c.get('id') in failed: + continue + written, files, cmd = tool_targets(ctx, c.get('name', ''), c.get('input') or {}) + if i >= start: + if files: + f['landed'] = True + f['turn_files'] += files + if cmd and ctx.landing_bash.search(cmd): + f['landed'] = True + if COMMIT_BASH.search(cmd): + f['committed'] = True + f['turn_commits'].append(re.sub(r'\s+', ' ', cmd)[:160]) + if written and floor and iso_epoch(d.get('timestamp') or '') < floor: + continue + for jn in written: + if jn not in f['session_journal_writes']: + f['session_journal_writes'].append(jn) + f['last_journal_write_ts'] = d.get('timestamp') + if i >= start: + f['turn_journal_writes'].add(jn) + return f + + +def journals_changed_since(ctx, t0): + out = [] + try: + for e in os.scandir(ctx.jdir): + if NAME.match(e.name) and e.stat().st_mtime >= t0 - 1.0: + out.append(e.name) + except OSError: + pass + return sorted(out) + + +# ---------------------------------------------------------------- lease helpers + +def new_lease(ctx, inp): + role = (os.environ.get('JOURNAL_ROLE') or '').strip().lower() + role = role if role in ('worker', 'lead') else '' + return {'v': 2, 'sid': inp.get('session_id') or 'nosid', 'journal': None, 'bound_at': None, + 'role': role or 'lead', 'role_source': 'env' if role else 'default', + 'root': ctx.root, 'worktree': ctx.worktree, 'branch': ctx.branch, 'started': time.time(), 'pid': env_pid(), + 'transcript': inp.get('transcript_path'), + 'runtime': 'codex' if 'turn_id' in inp or '/.codex/' in str(inp.get('transcript_path') or '') + else (os.environ.get('CLAUDE_CODE_ENTRYPOINT') or 'claude'), + 'ended': None, 'history': [], 'writes': []} + + +def get_lease(ctx, inp): + sid = inp.get('session_id') or 'nosid' + lease = ctx.lease(sid) or new_lease(ctx, inp) + lease['transcript'] = inp.get('transcript_path') or lease.get('transcript') + lease['pid'] = env_pid() or lease.get('pid') + lease['ended'] = None + if 'turn_id' in inp: + lease['runtime'] = 'codex' + return lease + + +def hard_worker(lease): + return lease.get('role') == 'worker' and lease.get('role_source') in ('env', 'cli') + + +def skipping(lease): + return (os.environ.get('SESSION_JOURNAL_SKIP') or '').lower() not in ('', '0', 'false', 'no') or lease.get('skip') + + +def bind(ctx, lease, name, why): + if name and lease.get('journal') != name: + if lease.get('journal'): + lease.setdefault('history', []).append(lease['journal']) + lease['journal'] = name + lease['bound_at'] = time.time() + ctx.trace(ev='bind', sid=lease['sid'], journal=name, why=why) + + +def turn_key(inp, lease, fallback=None): + """One key per human prompt: Claude Code's prompt_id, Codex's turn_id, else the UserPromptSubmit counter.""" + k = inp.get('prompt_id') or inp.get('turn_id') + if k: + return str(k) + if lease.get('prompt_seq'): + return f"seq-{lease['prompt_seq']}" + return fallback or 'unknown' + + +def turn_record(lease, key): + t = lease.get('turn') or {} + if t.get('key') != key: + t = {'key': key, 'landed': False, 'committed': False, 'journals': [], 'files': [], 'commits': []} + lease['turn'] = t + return t + + +def tokens(s): + return [w for w in re.findall(r'[a-z0-9]+', (s or '').lower().replace('_', ' ')) + if len(w) >= 3 and w not in STOPWORDS] + + +def candidates(ctx, lease, limit=5, first=None): + """Open journals a starting session might continue, ranked: same worktree, journal dirty in git, ACTIVE, and the + last write by an agent session (a background job bumping mtime does not count).""" + dirty = set() + for line in git('status', '--porcelain', '--', ctx.jdir, cwd=ctx.main).splitlines(): + dirty.add(os.path.basename(line[3:].strip().strip('"'))) + session_writes = {} + for l in ctx.leases(): + for w in l.get('writes') or []: + session_writes[w[0]] = max(session_writes.get(w[0], 0), w[1]) + rows = [] + for p in ctx.journals()[-300:]: + c = card(ctx, os.path.basename(p)) + if not c or c['status'] in CLOSED: + continue + owners = ctx.owners(c['journal'], me=lease) + last = session_writes.get(c['journal']) or c['mtime'] + score = -((time.time() - last) / 3600.0) + where = c['worktree'] or c['sessions'] + if where and lease.get('worktree') and lease['worktree'] != 'main' and lease['worktree'] in where: + score += 48 + if c['label'].upper().startswith(('ACTIVE', 'IN PROGRESS')): + score += 6 + if c['journal'] in dirty: + score += 72 + if first and c['journal'] == first: + score += 10 ** 6 + rows.append((score, c, owners)) + rows.sort(key=lambda r: -r[0]) + return rows[:limit] + + +def fmt_card(ctx, c, owners): + if owners: + o = owners[0] + tag = f"IN USE by live session {o['sid'][:8]} ({o.get('worktree', '?')}) - read only, do not append" + else: + tag = c['label'] + (f", {c['bytes'] // 1024} KB - continue it in a new journal with Continues:" + if c['bytes'] > ctx.max_bytes else '') + return f" - {ctx.show(c['journal'])} [{tag}] {c['title'][:70]} | next: {c['next'][:160]}" + + +def fmt_candidates(ctx, rows): + return '\n'.join(fmt_card(ctx, c, owners) for _, c, owners in rows) + + +def related(ctx, lease, prompt, exclude, limit=2, threshold=4.0): + """Open journals whose name or title match the first prompt (IDF-weighted) and were not listed at start-up.""" + q = set(tokens(prompt[:2000])) + if not q: + return [] + docs = {} + for p in ctx.journals(): + n = os.path.basename(p) + try: + with open(p, errors='replace') as fh: + head = fh.read(3000) + except OSError: + continue + m = re.search(r'^# (?:Session: )?(.+)$', head, re.M) + slug = re.sub(r'^\d{4}-\d\d-\d\d-(?:\d{4}-)?|\.md$', '', n).replace('-', ' ') + docs[n] = set(tokens(slug + ' ' + (m.group(1) if m else ''))) + df = collections.Counter(w for s in docs.values() for w in s) + n_docs = max(len(docs), 1) + scored = sorted(((sum(math.log(n_docs / (1 + df[w])) for w in s & q), n) for n, s in docs.items() + if n not in exclude), reverse=True) + out = [] + for sc, n in scored[:limit]: + if sc <= threshold: + break + c = card(ctx, n) + if c and c['status'] not in CLOSED: + out.append((c, ctx.owners(n, me=lease))) + return out + + +def predecessor_of(ctx, lease): + """The session this window ran before /clear: it ended with reason 'clear' moments ago in the same process.""" + best = None + for l in ctx.leases(): + if l.get('sid') == lease['sid'] or l.get('end_reason') != 'clear' or not l.get('ended'): + continue + if time.time() - l['ended'] > 600: + continue + same = (lease.get('pid') and l.get('pid') == lease.get('pid')) or \ + (not lease.get('pid') and l.get('root') == lease.get('root') and time.time() - l['ended'] < 30) + if same and (best is None or l['ended'] > best['ended']): + best = l + return best + + +# ---------------------------------------------------------------- hook handlers + +def on_session_start(ctx, inp): + src = inp.get('source') or 'startup' + msgs = [] + parent_journal = None + if src == 'fork': + hist = turn_facts(ctx, read_transcript(inp.get('transcript_path')))['session_journal_writes'] + parent_journal = hist[-1] if hist else None + with ctx.lock(): + lease = get_lease(ctx, inp) + if src == 'fork' and not lease.get('bind_floor'): + lease['bind_floor'] = time.time() # history before the fork belongs to the parent session + lease['source'] = 'fork' + pred = predecessor_of(ctx, lease) if src == 'clear' and not lease.get('journal') else None + if src == 'clear' and not pred and not lease.get('journal'): + time.sleep(0.3) # the old session's SessionEnd may still be finishing + pred = predecessor_of(ctx, lease) + if pred: + lease['predecessor'] = pred['sid'] + ctx.save_lease(lease) + readme = f'{ctx.jrel}/README.md' + if lease['role'] == 'worker': + msgs.append(f'Journal: this session is a WORKER (JOURNAL_ROLE=worker). Do not create or edit {ctx.jrel}/*.md; ' + 'report results to your lead, whose journal records the dispatch and the outcome.') + elif lease.get('journal'): + c = card(ctx, lease['journal']) or {} + msgs.append(f"Journal: this session ({lease['sid'][:8]}) is bound to {ctx.jpath(lease['journal'])} " + f"(source={src}). Continue it; re-read it now if context was compacted. " + f"Next step recorded there: {c.get('next', '')[:300]}") + else: + msgs.append( + f"Journal: no journal is bound to this session ({lease['sid'][:8]}) yet. Journals live in {ctx.jdir}" + + (f" (the main checkout; you are in worktree '{ctx.worktree}', whose own copy of {ctx.jrel}/ is stale: " + f"give a new journal the header line '**Worktree:** {ctx.worktree} ({ctx.branch})')" + if ctx.worktree != 'main' else '') + + ". After the user's first message: if the task continues one of the candidates below, read it and " + f"append; otherwise create {ctx.jrel}/YYYY-MM-DD-HHMM-.md (local time) from {ctx.jrel}/TEMPLATE.md, " + f"following {readme}, and add '**Continues:** {ctx.jrel}/.md' when it picks up earlier work. Your " + "first journal write binds it to this session. Never append to a journal marked IN USE. A turn that " + "only reads or answers needs no journal.") + first = pred.get('journal') if pred else None + if pred and first: + msgs.append(f"You ran /clear: the previous session in this window ({pred['sid'][:8]}) was bound to " + f"{ctx.show(first)}. If this task continues that work, append to it (it is free now); " + "otherwise start a new journal.") + if parent_journal: + msgs.append(f"This session is a fork. The journal in its history, {ctx.show(parent_journal)}, belongs to " + f"the original session: create your own journal with '**Continues:** " + f"{ctx.show(parent_journal)}'.") + rows = candidates(ctx, lease, first=first) + if rows: + msgs.append('Candidates (ranked: same worktree, dirty in git, active, recent):\n' + fmt_candidates(ctx, rows)) + with ctx.lock(): + cur = ctx.lease(lease['sid']) or lease + cur['shown'] = [c['journal'] for _, c, _ in rows] + ctx.save_lease(cur) + msgs.append(f"Older work: `python3 {ctx.script} find `; open journals on a topic: " + f"`python3 {ctx.script} candidates `; a journal's history: `... reconstruct `.") + ctx.trace(ev='SessionStart', sid=lease['sid'], source=src, bound=lease.get('journal'), role=lease['role'], + predecessor=(pred or {}).get('sid')) + return {'hookSpecificOutput': {'hookEventName': 'SessionStart', 'additionalContext': '\n'.join(msgs)}} + + +def on_user_prompt(ctx, inp): + prompt = inp.get('prompt', '') or '' + texts = [] + worker_rx = os.environ.get('JOURNAL_WORKER_PROMPT') + with ctx.lock(): + lease = get_lease(ctx, inp) + lease['prompt_seq'] = (lease.get('prompt_seq') or 0) + 1 + first = not lease.get('prompted') + if first: + lease['prompted'] = True + try: + dispatched = bool(worker_rx) and re.search(worker_rx, prompt, re.I | re.S) + except re.error: + dispatched = False + if lease['role_source'] == 'default' and dispatched and not LEAD_OVERRIDE.search(prompt): + lease['role'], lease['role_source'] = 'worker', 'dispatch-prompt' + texts.append('Journal: this prompt is a dispatched task, so this session counts as a WORKER and is ' + 'not nudged about journals. Write a journal only if your task asks for one; otherwise ' + 'your report to the lead is the record.') + ctx.trace(ev='role', sid=lease['sid'], role='worker', source='dispatch-prompt') + news = unseen_ledger(ctx, lease) if lease['role'] != 'worker' else [] + ctx.save_lease(lease) + if first and lease['role'] != 'worker' and not lease.get('journal'): + rel = related(ctx, lease, prompt, exclude=set(lease.get('shown') or [])) + if rel: + texts.append('Journals that match this first prompt (in addition to the start-up candidates):\n' + + '\n'.join(fmt_card(ctx, c, o) for c, o in rel)) + if news: + texts.append('Journal: worker results landed since you last looked (record the outcome in YOUR journal, ' + 'one line each, when you act on them):\n' + '\n'.join(news)) + if not texts: + return None + return {'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit', 'additionalContext': '\n'.join(texts)}} + + +def ledger(ctx): + p = os.path.join(ctx.sdir, 'worker-ledger.jsonl') + out = [] + for line in (read(p) or '').splitlines(): + try: + out.append(json.loads(line)) + except Exception: + pass + return out + + +def unseen_ledger(ctx, lease): + seen = lease.get('ledger_seen') or lease.get('started') or 0 + rows = [e for e in ledger(ctx) if e.get('t', 0) > seen and e.get('sid') != lease['sid']] + if rows: + lease['ledger_seen'] = max(e['t'] for e in rows) + return [f" - worker {e['sid'][:8]} ({e.get('worktree')}/{e.get('branch')}): {e.get('head') or 'no commit'} | " + f"{e.get('summary', '')[:180]}" for e in rows[-8:]] + + +def deny(reason): + return {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'deny', + 'permissionDecisionReason': reason}} + + +def tool_text(name, ti): + """The new text a tool call puts into a file.""" + if name == 'apply_patch': + return patch_added_text(str(ti.get('command') or ti.get('patch') or ti.get('input') or '')) + if name == 'Bash': + return str(ti.get('command', '')) + return ti.get('content') or ti.get('new_string') or ti.get('new_source') or \ + '\n'.join(e.get('new_string', '') for e in ti.get('edits') or [] if isinstance(e, dict)) + + +def tool_may_matter(event, inp): + """Fast path: decide without git or file I/O whether this tool call can matter to the journal hooks.""" + tool, ti = inp.get('tool_name', ''), inp.get('tool_input') or {} + tail = os.path.basename(os.path.normpath(os.environ.get('SESSION_JOURNAL_DIR') or 'sessions')) + '/' + blob = json.dumps(ti) if isinstance(ti, dict) else str(ti) + if tail in blob.replace('\\\\', '/').replace('\\/', '/'): + return True + if event == 'PostToolUse': # landed work is recorded for runtimes without a usable transcript + if tool in LANDING_TOOLS: + return True + if tool == 'Bash': + cmd = str((ti or {}).get('command', '')) + extra = os.environ.get('JOURNAL_LANDING_BASH') + return bool(COMMIT_BASH.search(cmd) or (extra and re.search(extra, cmd))) + return False + + +def same_repo_worktree_journal_dir(ctx, path): + """True when path is //; other repositories are not ours.""" + d = os.path.dirname(os.path.abspath(path)) + while d and not os.path.isdir(d): + d = os.path.dirname(d) + common = git('rev-parse', '--git-common-dir', cwd=d) + return bool(common) and bool(ctx.common) and \ + os.path.realpath(os.path.join(d, common)) == os.path.realpath(ctx.common) + + +def pending_path(ctx, inp): + key = re.sub(r'[^A-Za-z0-9_-]', '', (inp.get('session_id') or 'nosid') + '-' + (inp.get('tool_use_id') or 'x')) + return os.path.join(ctx.sdir, 'pending', key) + + +def in_jdir(ctx, path): + return os.path.realpath(os.path.dirname(os.path.join(ctx.cwd, path))) == os.path.realpath(ctx.jdir) + + +def on_pre_tool(ctx, inp): + tool, ti = inp.get('tool_name', ''), inp.get('tool_input') or {} + names, paths = [], [] + if tool in LANDING_TOOLS: + if tool == 'apply_patch': + paths = [p for p in patch_paths(str(ti.get('command') or ''), ctx.cwd) if ctx.journal_name(p)] + else: + p = ti.get('file_path') or ti.get('notebook_path') or '' + paths = [p] if ctx.journal_name(p) else [] + names = [ctx.journal_name(p) for p in paths] + elif tool == 'Bash': + names = ctx.bash_journal_writes(str(ti.get('command', '')), cwd=ctx.cwd) + try: # PostToolUse compares journal mtimes against this to catch variable-path writes + with open(pending_path(ctx, inp), 'w') as fh: + fh.write(str(time.time())) + except OSError: + pass + if not names: + return None + for p in paths: + if not in_jdir(ctx, p): + if same_repo_worktree_journal_dir(ctx, p): + return deny(f'Journals live only in the main checkout: write {ctx.jpath(ctx.journal_name(p))} instead ' + f'of {p} (a linked worktree has its own stale copy of {ctx.jrel}/).') + return None # another repository's file that happens to look like a journal + lease = ctx.lease(inp.get('session_id') or 'nosid') or new_lease(ctx, inp) + if hard_worker(lease): + ctx.trace(ev='deny', sid=lease['sid'], why='worker', journal=names[0]) + return deny(f'This session is a worker (JOURNAL_ROLE=worker): do not write session journals. Put results in ' + f'your report to the lead. If this session should keep a journal, run ' + f'`python3 {ctx.script} role lead` and retry.') + for jn in names: + owners = ctx.owners(jn, me=lease) + if owners: + o = owners[0] + ctx.trace(ev='deny', sid=lease['sid'], why='in-use', journal=jn, owner=o['sid']) + return deny(f"{ctx.show(jn)} is in use by live session {o['sid'][:8]} (worktree {o.get('worktree')}, " + f"branch {o.get('branch')}). Create your own journal and add '**Continues:** {ctx.show(jn)}' " + f"to link it. (If that session is gone, run `python3 {ctx.script} release {o['sid'][:8]}`.)") + if ctx.secrets == 'deny': + hits = scan_secrets(tool_text(tool, ti)) + if hits: + ctx.trace(ev='deny', sid=lease['sid'], why='secret', journal=names[0], kinds=[h[1] for h in hits]) + return deny(f'The text for {ctx.show(names[0])} looks like it contains a credential ({fmt_hits(hits)} of ' + 'the new text; values are not shown). Journals are committed and long-lived: write where the ' + 'secret lives instead (a vault or secret name, an env-var reference, or ). If a ' + f'human has verified a line carries no credential, append {SKIP_OK} to that line.') + return None + + +def on_post_tool(ctx, inp): + tool, ti = inp.get('tool_name', ''), inp.get('tool_input') or {} + notes = [] + names, files, cmd = tool_targets(ctx, tool, ti, cwd=ctx.cwd) + if tool in LANDING_TOOLS: + paths = patch_paths(str(ti.get('command') or ''), ctx.cwd) if tool == 'apply_patch' else \ + [ti.get('file_path') or ti.get('notebook_path') or ''] + names = [ctx.journal_name(p) for p in paths if ctx.journal_name(p) and in_jdir(ctx, p)] + elif cmd is not None: + t0 = None + try: + p = pending_path(ctx, inp) + with open(p) as fh: + t0 = float(fh.read().strip() or 0) + os.remove(p) + except (OSError, ValueError): + pass + if t0: + extra = [n for n in journals_changed_since(ctx, t0) if n not in names] + if len(extra) > 2: # a bulk rewrite of many journals binds nothing + ctx.trace(ev='bulk-journal-change', sid=inp.get('session_id'), n=len(extra)) + extra = [] + names = names + extra + names = [n for n in dict.fromkeys(names) if n] + landed = bool(files) or bool(cmd and ctx.landing_bash.search(cmd)) + if not names and not landed: + return None + with ctx.lock(): + lease = get_lease(ctx, inp) + key = turn_key(inp, lease) + turn = turn_record(lease, key) + if landed: + turn['landed'] = True + turn['files'] = (turn['files'] + [f for f in files if f])[-20:] + if cmd and COMMIT_BASH.search(cmd): + turn['committed'] = True + turn['commits'] = (turn['commits'] + [re.sub(r'\s+', ' ', cmd)[:160]])[-5:] + if names and hard_worker(lease): + ctx.save_lease(lease) + return {'hookSpecificOutput': {'hookEventName': 'PostToolUse', 'additionalContext': + f"You are a worker (JOURNAL_ROLE=worker) and just changed {ctx.show(names[-1])}. Revert that " + "change; report to your lead instead."}} + for jn in names: + owners = ctx.owners(jn, me=lease) + if owners: + notes.append(f"{ctx.show(jn)} is in use by live session {owners[0]['sid'][:8]}; move what you just " + f"wrote into your own journal (with '**Continues:** {ctx.show(jn)}') and revert it there.") + free = [jn for jn in names if not ctx.owners(jn, me=lease)] + if free: + bind(ctx, lease, free[-1], 'write') + lease['last_write'] = time.time() + lease['writes'] = (lease.get('writes') or [])[-50:] + [[free[-1], time.time(), key]] + lease['unjournaled_turns'] = 0 + turn['journals'] = sorted(set(turn['journals']) | set(free)) + c = card(ctx, free[-1]) + if c and c['bytes'] > ctx.max_bytes and lease.get('size_warned') != free[-1]: + lease['size_warned'] = free[-1] + notes.append(f"{ctx.show(free[-1])} is {c['bytes'] // 1024} KB. Finish this update, then continue the " + f"work in a new journal whose header says '**Continues:** {ctx.show(free[-1])}'.") + ctx.save_lease(lease) + if names and ctx.secrets == 'warn': + hits = scan_secrets(tool_text(tool, ti)) + if hits: + ctx.trace(ev='secret-warning', sid=lease['sid'], journal=names[-1], kinds=[h[1] for h in hits]) + notes.append(f"The text you just wrote to {ctx.show(names[-1])} looks like it contains a credential " + f"({fmt_hits(hits)}). Replace the value with where the secret lives.") + if notes: + return {'hookSpecificOutput': {'hookEventName': 'PostToolUse', 'additionalContext': '\n'.join(notes)}} + return None + + +def on_stop(ctx, inp): + sid = inp.get('session_id') or 'nosid' + pre = ctx.lease(sid) or {} + f = turn_facts(ctx, read_transcript(inp.get('transcript_path')), floor=pre.get('bind_floor') or 0) + nudge = None + with ctx.lock(): + lease = get_lease(ctx, inp) + key = turn_key(inp, lease, fallback=f['turn_start_ts']) + t0 = iso_epoch(f['turn_start_ts'] or '') - 2 + turn = turn_record(lease, key) + # bind from the transcript too (Bash/python writes), never onto a live owner's journal + if f['session_journal_writes'] and not hard_worker(lease): + jn = f['session_journal_writes'][-1] + if not ctx.owners(jn, me=lease): + bind(ctx, lease, jn, 'transcript') + if f['last_journal_write_ts']: + lease['last_write'] = max(lease.get('last_write') or 0, iso_epoch(f['last_journal_write_ts'])) + hook_writes = [w[0] for w in lease.get('writes') or [] + if (len(w) > 2 and w[2] == key) or (key == f['turn_start_ts'] and t0 > 0 and w[1] >= t0)] + touched = set(f['turn_journal_writes']) | set(hook_writes) | set(turn['journals']) + landed = f['landed'] or turn['landed'] + committed = f['committed'] or turn['committed'] + if touched: + lease['unjournaled_turns'] = 0 + elif landed and lease.get('counted') != key: + lease['unjournaled_turns'] = lease.get('unjournaled_turns', 0) + 1 # once per turn, not per Stop round + lease['counted'] = key + if lease['role'] == 'worker' and landed and not inp.get('stop_hook_active') and lease.get('ledgered') != key: + entry = {'t': time.time(), 'sid': lease['sid'], 'worktree': lease.get('worktree'), + 'branch': lease.get('branch'), 'task': (f['first_prompt'] or '')[:200], + 'head': git('log', '-1', '--format=%h %s', cwd=ctx.root) if committed else None, + 'commits': (f['turn_commits'] or turn['commits'])[-5:], + 'files': sorted({os.path.relpath(x, ctx.main) if x.startswith(ctx.main) else x + for x in f['turn_files'] + turn['files'] if x})[:20], + 'summary': re.sub(r'\s+', ' ', inp.get('last_assistant_message') or f['last_text'])[:400]} + with open(os.path.join(ctx.sdir, 'worker-ledger.jsonl'), 'a') as fh: + fh.write(json.dumps(entry) + '\n') + lease['ledgered'] = key + why = None + if not landed: + result = 'readonly' + elif touched: + result = 'fresh' + elif lease['role'] == 'worker': + result, why = 'skip', 'worker' + elif skipping(lease): + result, why = 'skip', 'skip' + elif inp.get('stop_hook_active') or lease.get('nudged') == key: + result, why = 'skip', 'already-nudged' + else: + since = (time.time() - (lease.get('last_write') or lease.get('started') or time.time())) / 60 + due = committed or not lease.get('journal') or lease.get('unjournaled_turns', 0) >= 2 or \ + since >= ctx.nudge_min + if not due: + result = 'debounced' + else: + result = 'nudge' + lease['nudged'] = key + nudge = (lease.get('journal'), committed, lease.get('unjournaled_turns', 0), since) + ctx.save_lease(lease) + ctx.trace(ev='stop', sid=sid, result=result, why=why, journal=lease.get('journal'), + unjournaled=lease.get('unjournaled_turns')) + if not nudge: + return None + journal, committed, unjournaled, since = nudge + if not journal: + rows = candidates(ctx, lease, 3) + msg = (f"SESSION-JOURNAL: this turn landed work but this session has no journal yet. Create " + f"{ctx.jdir}/YYYY-MM-DD-HHMM-.md (local time) from {ctx.jrel}/TEMPLATE.md, or append to a " + f"candidate it truly continues (never one marked IN USE). If this session needs no journal, say so " + f"and stop." + + (f"\n{fmt_candidates(ctx, rows)}" if rows else '')) + else: + c = card(ctx, journal) or {} + pointer = c.get('pointer', 'none') + why = 'a commit or push landed' if committed else \ + f"{unjournaled} landed turns / {since:.0f} min since its last update" + if is_thin(pointer): + msg = (f"SESSION-JOURNAL (THIN): add one milestone line to YOUR journal {ctx.jpath(journal)} ({why}) that " + f"points at {pointer.strip('`- ')} (section, version or commit), and bump 'Last updated'; change " + "'Where we are now' or 'Next step' only if they moved. Do not re-narrate the plan.") + else: + msg = (f"SESSION-JOURNAL: update YOUR journal {ctx.jpath(journal)} ({why}). DETAILED mode: milestones, " + "evidence, commits, files touched. Refresh 'Last updated', 'Where we are now' and 'Next step for " + "a fresh agent'.") + return {'decision': 'block', 'reason': msg} + + +def stamp_session_line(ctx, name, lease, status=None): + """Hook-managed header lines, written only when no live session is editing the journal.""" + p = ctx.jpath(name) + text = read(p) + if text is None: + return + tag = f"{lease['sid'][:8]} ({lease.get('worktree', '?')}/{lease.get('branch', '?')}, {lease.get('runtime') or 'agent'})" + new = text + sessions = header(new, 'Sessions') + if lease['sid'][:8] not in sessions: + if sessions: + new = re.sub(r'^(\*\*Sessions:\*\*\s*)(.+)$', lambda m: m.group(1) + m.group(2) + ', ' + tag, new, count=1, + flags=re.M) + else: + new = re.sub(r'^(\*\*Status:\*\*.*)$', lambda m: m.group(1) + '\n**Sessions:** ' + tag, new, count=1, + flags=re.M) + if status and header(new, 'Status').upper().startswith('ACTIVE'): + new = re.sub(r'^(\*\*Status:\*\*\s*)ACTIVE\b.*$', lambda m: m.group(1) + status, new, count=1, + flags=re.M | re.I) + if new != text: + tmp = f'{p}.{os.getpid()}.tmp' + with open(tmp, 'w') as fh: + fh.write(new) + os.replace(tmp, p) + + +def on_session_end(ctx, inp): + reason = inp.get('reason') or 'other' + with ctx.lock(): + lease = ctx.lease(inp.get('session_id') or 'nosid') + if not lease: + return None + lease['ended'] = time.time() + lease['end_reason'] = reason + ctx.save_lease(lease) + if lease.get('journal') and lease['role'] != 'worker' and not ctx.owners(lease['journal']): + # /clear starts a new session in the same window, which usually carries on: record it, keep the status. + pause = reason != 'clear' and os.environ.get('JOURNAL_PAUSE_ON_END', '1') not in ('0', 'false', 'no') + stamp_session_line(ctx, lease['journal'], lease, + status='PAUSED (auto: session ended without DONE)' if pause else None) + ctx.trace(ev='SessionEnd', sid=lease['sid'], journal=lease.get('journal'), reason=reason) + return None + + +HANDLERS = {'SessionStart': on_session_start, 'UserPromptSubmit': on_user_prompt, 'PreToolUse': on_pre_tool, + 'PostToolUse': on_post_tool, 'Stop': on_stop, 'SessionEnd': on_session_end} + + +def run_hook(event): + out = None + try: + if os.environ.get('SESSION_JOURNAL_DISABLE') not in (None, '', '0') or event not in HANDLERS: + return 0 + raw = sys.stdin.read() if not sys.stdin.isatty() else '' + try: + inp = json.loads(raw) if raw.strip() else {} + except Exception: + inp = {} + if not isinstance(inp, dict): + inp = {} + if event in ('PreToolUse', 'PostToolUse') and not tool_may_matter(event, inp): + return 0 # most tool calls: no git, no file I/O + ctx = None + try: + ctx = Ctx(inp.get('cwd')) + if not ctx.enabled: + return 0 + out = HANDLERS[event](ctx, inp) + except Exception as e: # a journal hook must never break the session + if ctx is not None and ctx.enabled: + ctx.trace(ev='error', event=event, err=repr(e)[:300]) + out = None + return 0 + finally: + if out: + print(json.dumps(out)) + elif event == 'Stop': + print('{}') # Codex requires JSON on stdout from a Stop hook that exits 0 + + +# ---------------------------------------------------------------- CLI + +def transcripts_for(ctx, sid_prefix): + for l in ctx.leases(): + if l['sid'].startswith(sid_prefix) and l.get('transcript') and os.path.exists(l['transcript']): + return l['transcript'] + root = os.environ.get('JOURNAL_TRANSCRIPTS_DIR') or os.path.expanduser('~/.claude/projects') + hits = glob.glob(os.path.join(root, '*', sid_prefix + '*.jsonl')) + return hits[0] if hits else None + + +def session_brief(ctx, path, max_prompts=6): + entries = read_transcript(path) + prompts, files, commits, last = [], [], [], '' + for d in entries: + if is_human(d): + c = (d.get('message') or {}).get('content') + t = c if isinstance(c, str) else ' '.join(x.get('text', '') for x in c or [] if isinstance(x, dict)) + prompts.append((d.get('timestamp', '')[:16], re.sub(r'\s+', ' ', t)[:200])) + if d.get('type') == 'assistant' and not d.get('isSidechain'): + for c in (d.get('message') or {}).get('content') or []: + if isinstance(c, dict) and c.get('type') == 'tool_use': + _, fs, cmd = tool_targets(ctx, c.get('name', ''), c.get('input') or {}) + files += fs + if cmd and COMMIT_BASH.search(cmd): + commits.append(re.sub(r'\s+', ' ', cmd)[:160]) + elif isinstance(c, dict) and c.get('type') == 'text' and c.get('text'): + last = c['text'] + return {'first': entries[0].get('timestamp', '') if entries else '', + 'last': entries[-1].get('timestamp', '') if entries else '', 'prompts': prompts[:max_prompts], + 'n_prompts': len(prompts), 'files': sorted(set(files)), 'commits': commits[-8:], + 'last_text': re.sub(r'\s+', ' ', last)[:600]} + + +def norm_name(ctx, name): + base = os.path.basename(name.rstrip('/')) + return base if NAME.match(base) else None + + +def cmd_reconstruct(ctx, name): + name = norm_name(ctx, name) or name + c = card(ctx, name) + if not c: + print(f'no such journal: {name}') + return 1 + text = read(ctx.jpath(name)) or '' + sids = set(re.findall(r'\b([0-9a-f]{8})\b', c['sessions'])) + sids |= {l['sid'][:8] for l in ctx.leases() if l.get('journal') == name or name in (l.get('history') or [])} + print(f"# Reconstruct {name}\n{c['title']} | status {c['status']} | updated {c['updated']} | {c['bytes']} bytes") + print(f"Plan pointer: {c['pointer']}\nContinues: {header(text, 'Continues') or '-'}\n\n## Next step (journal)\n" + f"{section(text, 'Next step for a fresh agent')}\n") + print('## Where we are now (journal)\n' + section(text, 'Where we are now') + '\n') + for s in sorted(sids): + tp = transcripts_for(ctx, s) + if not tp: + print(f'## Session {s}: transcript not found') + continue + b = session_brief(ctx, tp) + print(f"## Session {s} ({b['first'][:16]} -> {b['last'][:16]}, {b['n_prompts']} prompts)\nTranscript: {tp}") + for ts, p in b['prompts']: + print(f' - [{ts}] {p}') + if b['commits']: + print(' commits: ' + ' | '.join(b['commits'])) + if b['files']: + print(' files: ' + ', '.join(os.path.relpath(f, ctx.main) if f.startswith(ctx.main) else f + for f in b['files'][:25])) + print(f" last reply: {b['last_text']}\n") + later = [os.path.basename(p) for p in ctx.journals() if name in header(read(p) or '', 'Continues')] + if later: + print('## Continued by\n' + '\n'.join(' - ' + ctx.show(x) for x in later)) + return 0 + + +def cmd_find(ctx, words): + q = [w.lower() for w in words] + if not q: + print('usage: find ') + return 1 + rows = [] + for p in ctx.journals(): + low = (read(p) or '').lower() + score = sum(low.count(w) for w in q) + 5 * sum(w in os.path.basename(p).lower() for w in q) + if score and all(w in low for w in q): + rows.append((score, card(ctx, os.path.basename(p)))) + rows.sort(key=lambda r: (-r[0], -r[1]['mtime'])) + for s, c in rows[:15]: + print(f"{s:4d} {ctx.show(c['journal'])} [{c['label']}] {c['title'][:60]}\n next: {c['next'][:150]}") + wl = [e for e in ledger(ctx) if all(w in json.dumps(e).lower() for w in q)] + if wl: + print('\nWorker ledger (dispatched work that has no journal of its own):') + for e in wl[-10:]: + print(f" {dt.datetime.fromtimestamp(e['t']):%Y-%m-%d %H:%M} worker {e['sid'][:8]} " + f"{e.get('worktree')}/{e.get('branch')}: {e.get('head') or '-'} | {e.get('summary', '')[:150]}") + if not rows and not wl: + print('no journal matches') + return 0 + + +def caller_sid(): + return os.environ.get('SESSION_JOURNAL_SESSION_ID') or os.environ.get('CLAUDE_CODE_SESSION_ID') or \ + os.environ.get('CODEX_THREAD_ID') or '' + + +def cmd_candidates(ctx, words): + """The SessionStart view on demand: this session's bound journal, then open candidates (IN USE marked), optionally + only those whose text contains every word. Use it instead of picking the newest file by name or mtime.""" + sid = caller_sid() + lease = next((x for x in ctx.leases() if sid and x['sid'] == sid), None) or \ + {'sid': sid or 'cli', 'worktree': ctx.worktree, 'branch': ctx.branch} + if lease.get('journal') and lease.get('role') != 'worker': + print(f"bound: {ctx.show(lease['journal'])}") + q = [w.lower() for w in words] + rows = candidates(ctx, lease, limit=500 if q else 5) + if q: + rows = [r for r in rows if all(w in (read(ctx.jpath(r[1]['journal'])) or '').lower() for w in q)][:5] + print(fmt_candidates(ctx, rows) if rows else 'no open candidates' + (' match' if q else '')) + return 0 + + +def cmd_list(ctx, args): + now = time.time() + for l in sorted(ctx.leases(), key=lambda x: -(x.get('heartbeat') or 0)): + if '--all' not in args and not ctx.is_live(l): + continue + print(f"{l['sid'][:8]} {'LIVE' if ctx.is_live(l) else 'ended'} {l.get('role', '?'):6} " + f"{str(l.get('worktree')):>18}/{str(l.get('branch')):<22} hb {int((now - (l.get('heartbeat') or 0)) / 60)}m" + f" journal={l.get('journal') or '-'}") + return 0 + + +def health(ctx): + """Drift indicators: run it on a schedule, or before trusting the hooks.""" + leases = ctx.leases() + by_j = {} + for l in leases: + if l.get('journal'): + by_j.setdefault(l['journal'], set()).add(l['sid']) + cards = [c for c in (card(ctx, os.path.basename(p)) for p in ctx.journals()) if c] + stale_active = [c['journal'] for c in cards if c['status'] == 'ACTIVE' and time.time() - c['mtime'] > 7 * 86400 + and not ctx.owners(c['journal'])] + secrets = [] + for c in cards: + h = scan_secrets(read(ctx.jpath(c['journal']))) + if h: + secrets.append((c['journal'], sorted({x[1] for x in h}))) + ev = [] + for x in (read(os.path.join(ctx.sdir, 'events.jsonl')) or '').splitlines(): + try: + ev.append(json.loads(x)) + except Exception: + pass + + def cnt(**kw): + return sum(all(e.get(k) == v for k, v in kw.items()) for e in ev) + + landed = {e.get('sid') for e in ev if e.get('ev') == 'stop' and e.get('result') in ('nudge', 'debounced', 'fresh', 'skip')} + return { + 'version': VERSION, 'journal_dir': ctx.jdir, 'journals': len(cards), + 'open': sum(c['status'] not in CLOSED for c in cards), 'stale_active_7d': len(stale_active), + 'oversized': sum(c['bytes'] > ctx.max_bytes for c in cards), + 'multi_session_journals': sum(len(v) > 1 for v in by_j.values()), + 'secret_journals': len(secrets), 'secret_examples': secrets[:10], + 'leases': len(leases), 'live_sessions': sum(ctx.is_live(l) for l in leases), + 'workers': sum(l.get('role') == 'worker' for l in leases), + 'nudges': cnt(ev='stop', result='nudge'), 'debounced': cnt(ev='stop', result='debounced'), + 'fresh_turns': cnt(ev='stop', result='fresh'), + 'nudges_per_landed_session': round(cnt(ev='stop', result='nudge') / max(len(landed), 1), 2), + 'deny_in_use': cnt(ev='deny', why='in-use'), 'deny_worker': cnt(ev='deny', why='worker'), + 'deny_secret': cnt(ev='deny', why='secret'), 'secret_warnings': cnt(ev='secret-warning'), + 'clear_handoffs': sum(1 for e in ev if e.get('ev') == 'SessionStart' and e.get('predecessor')), + 'hook_errors': cnt(ev='error'), + } + + +def cmd_scan(ctx, paths): + """Secret scan of the given files, or of every journal. Prints line numbers and rule names, never values.""" + files = paths or ctx.journals() + bad = 0 + for p in files: + hits = scan_secrets(read(p) or '') + if hits: + bad += 1 + print(f'{p}: {fmt_hits(hits)}') + print(f'scan: {bad} of {len(files)} file(s) carry credential-shaped content') + return 1 if bad else 0 + + +def last_commit_dates(ctx): + """Latest commit time per journal in one git log pass (checkouts and clones reset mtimes).""" + out, cur = {}, None + for line in git('log', '--format=@%ct', '--name-only', '--', ctx.jdir, cwd=ctx.main, timeout=120).splitlines(): + if line.startswith('@'): + cur = int(line[1:]) + elif line.strip() and cur: + out.setdefault(os.path.basename(line.strip()), cur) + return out + + +def header_epoch(value): + m = re.search(r'(\d{4}-\d\d-\d\d)[ T](\d\d):(\d\d)', value or '') + if not m: + return 0 + try: + return time.mktime(time.strptime(f'{m.group(1)} {m.group(2)}:{m.group(3)}', '%Y-%m-%d %H:%M')) + except (ValueError, OverflowError): + return 0 + + +def cmd_sweep(ctx, args): + """End dead leases, drop ended leases older than 30 days, and PAUSE ACTIVE journals nobody live owns that went + quiet (default 24 h). --dry-run prints what would change. --by-git dates activity by last commit and the + 'Last updated' header (plus mtime when the journal is dirty) instead of checkout mtimes.""" + dry, by_git = '--dry-run' in args, '--by-git' in args + nums = [a for a in args if not a.startswith('--')] + try: + hours = float(nums[0]) if nums else 24 + except ValueError: + print('usage: sweep [hours] [--dry-run] [--by-git]') + return 1 + now, changes = time.time(), [] + for l in ctx.leases(): + if l.get('ended') and now - l['ended'] > 30 * 86400: + changes.append(f"drop lease {l['sid'][:8]} (ended {int((now - l['ended']) / 86400)}d ago)") + if not dry: + with contextlib.suppress(OSError): + os.remove(ctx.lease_path(l['sid'])) + elif not l.get('ended') and not ctx.is_live(l) and now - (l.get('heartbeat') or 0) > 6 * 3600: + changes.append(f"end lease {l['sid'][:8]} (journal {l.get('journal') or '-'})") + if not dry: + with ctx.lock(): + l['ended'], l['end_reason'] = now, 'sweep-timeout' + ctx.save_lease(l, touch=False) + if l.get('journal') and not ctx.owners(l['journal']): + stamp_session_line(ctx, l['journal'], l) + gdates = last_commit_dates(ctx) if by_git else {} + if by_git and not gdates and git('ls-files', '--', ctx.jdir, cwd=ctx.main): + print('sweep --by-git: git log over the journal directory returned nothing (timeout or error); refusing to ' + 'judge idleness by checkout mtimes. Re-run, or run without --by-git.') + return 2 + dirty = {os.path.basename(x[3:].strip()) for x in git('status', '--porcelain', '--', ctx.jdir, + cwd=ctx.main).splitlines()} if by_git else set() + agent = {} + for l in ctx.leases(): + for w in l.get('writes') or []: + agent[w[0]] = max(agent.get(w[0], 0), w[1]) + for p in ctx.journals(): + c = card(ctx, os.path.basename(p)) + if not c or c['status'] != 'ACTIVE' or ctx.owners(c['journal']): + continue + n = c['journal'] + base = gdates.get(n, 0) if by_git else c['mtime'] + if by_git and (n in dirty or not gdates.get(n)): + base = max(base, c['mtime']) + act = max(base, header_epoch(c['updated']), agent.get(n, 0)) + if now - act > hours * 3600: + changes.append(f'pause {ctx.show(n)} (idle {int((now - act) / 86400)}d)') + if not dry: + text = read(p) or '' + text = re.sub(r'^(\*\*Status:\*\*\s*)ACTIVE\b.*$', + lambda m: m.group(1) + f'PAUSED (auto: idle >{hours:.0f}h)', text, count=1, flags=re.M) + tmp = f'{p}.{os.getpid()}.tmp' + with open(tmp, 'w') as fh: + fh.write(text) + os.replace(tmp, p) + for ch in changes: + print((' would ' if dry else ' ') + ch) + print(f"sweep{' (dry run)' if dry else ''}: {len(changes)} changes") + return 0 + + +USAGE = ('commands: candidates [words..] | find | reconstruct | list [--all] | health | ' + 'scan [files..] | sweep [hours] [--dry-run] [--by-git] | whoami [sid] | bound [sid] | ' + 'bind [sid] [--force] | release | role lead|worker [sid] | skip [sid] | version\n' + '(sid defaults to $CLAUDE_CODE_SESSION_ID or $CODEX_THREAD_ID; set SESSION_JOURNAL_SESSION_ID to override)') + + +def main(argv): + if len(argv) >= 2 and argv[0] == 'hook': + return run_hook(argv[1]) + cmd, args = (argv[0] if argv else 'help'), argv[1:] + if cmd in ('version', '--version'): + print(VERSION) + return 0 + if cmd in ('help', '-h', '--help'): + print(__doc__) + print(USAGE) + return 0 + ctx = Ctx() + if not ctx.enabled: # run from elsewhere: use the project this script is installed in + ctx = Ctx(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + if not ctx.enabled: + print(f'no journal directory at {ctx.jdir}') + return 1 + sid_arg = lambda a: (a[0] if a else '') or caller_sid() + find_lease = lambda sid: next((x for x in ctx.leases() if sid and x['sid'].startswith(sid)), None) + if cmd == 'list': + return cmd_list(ctx, args) + if cmd == 'whoami': + l = find_lease(sid_arg(args)) + print(json.dumps(l, indent=1) if l else 'unknown session (no lease; pass a session id prefix)') + return 0 + if cmd == 'bound': + l = find_lease(sid_arg(args)) + if l and l.get('journal') and l.get('role') != 'worker': + print(ctx.jpath(l['journal'])) + return 0 + if cmd == 'find': + return cmd_find(ctx, args) + if cmd == 'candidates': + return cmd_candidates(ctx, args) + if cmd == 'reconstruct' and args: + return cmd_reconstruct(ctx, args[0]) + if cmd == 'health': + print(json.dumps(health(ctx), indent=1)) + return 0 + if cmd == 'scan': + return cmd_scan(ctx, args) + if cmd == 'sweep': + return cmd_sweep(ctx, args) + if cmd == 'release' and args: + with ctx.lock(): + for l in ctx.leases(): + if l['sid'].startswith(args[0]): + l['ended'], l['end_reason'] = time.time(), 'released' + ctx.save_lease(l, touch=False) + print('released', l['sid']) + return 0 + if cmd in ('role', 'skip'): + if cmd == 'role' and not (args and args[0] in ('lead', 'worker')): + print('usage: role lead|worker [sid]') + return 1 + rest = args[1:] if cmd == 'role' else args + with ctx.lock(): + l = find_lease(sid_arg(rest)) + if not l: + print('unknown session (pass a session id prefix)') + return 1 + if cmd == 'role': + l['role'], l['role_source'] = args[0], 'cli' + else: + l['skip'] = True + ctx.save_lease(l) + print(f"{l['sid'][:8]} " + (f'role={args[0]}' if cmd == 'role' else 'skip: no journal nudges this session')) + return 0 + if cmd == 'bind' and args: + plain = [a for a in args if not a.startswith('--')] + sid, jn = (caller_sid(), plain[0]) if len(plain) == 1 else (plain[0], plain[1]) + jn = norm_name(ctx, jn) + if not sid or not jn: + print('usage: bind [] (sid defaults to the caller session id)') + return 1 + with ctx.lock(): + l = find_lease(sid) or {'sid': sid, 'role': 'lead', 'role_source': 'default', 'worktree': ctx.worktree, + 'branch': ctx.branch, 'history': [], 'writes': [], 'started': time.time()} + owners = ctx.owners(jn, me=l) + if owners and '--force' not in args: + print(f"{ctx.show(jn)} is in use by live session {owners[0]['sid'][:8]}; use --force to bind anyway") + return 1 + bind(ctx, l, jn, 'manual') + ctx.save_lease(l) + return 0 + print(USAGE) + return 1 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/tests/nudge.bats b/tests/nudge.bats deleted file mode 100644 index da0ec64..0000000 --- a/tests/nudge.bats +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env bats - -load test_helper - -setup() { setup_workspace; } -teardown() { teardown_workspace; } - -@test "missing journal blocks once and empty input marker prevents a loop" { - run "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" - [ "$status" -eq 2 ] - [[ "$output" == *"No session journal exists"* ]] - - run "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" - [ "$status" -eq 0 ] -} - -@test "fresh journal succeeds" { - write_journal >/dev/null - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "stale detailed journal blocks with detailed wording" { - journal="$(write_journal)" - make_stale "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"DETAILED mode"* ]] -} - -@test "stale thin journal blocks with pointer-aware wording" { - journal="$(write_journal 2026-01-02-0304-thin.md docs/plans/work.md)" - make_stale "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"THIN mode"* ]] - [[ "$output" == *"docs/plans/work.md"* ]] -} - -@test "missing Live plan pointer is detailed mode" { - journal="$SESSION_JOURNAL_DIR/2026-01-02-0304-missing.md" - printf '# journal\n**Status:** ACTIVE\n' > "$journal" - make_stale "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"DETAILED mode"* ]] -} - -@test "stop_hook_active skips freshness nudge" { - journal="$(write_journal)" - make_stale "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":true}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "freshness override is honored" { - journal="$(write_journal)" - touch -t "$(date -v-2M +%Y%m%d%H%M 2>/dev/null || date -d '2 minutes ago' +%Y%m%d%H%M)" "$journal" - run env JOURNAL_FRESH_SECS=30 "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" <<< '{"stop_hook_active":false}' - [ "$status" -eq 2 ] -} - -@test "README only is treated as empty safely" { - printf '# docs\n' > "$SESSION_JOURNAL_DIR/README.md" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"No session journal exists"* ]] -} - -@test "pretty-printed hook JSON still honours stop_hook_active" { - journal="$(write_journal)" - make_stale "$journal" - run bash -c "printf '{\n \"stop_hook_active\": true\n}\n' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "an expired one-shot marker does not swallow a later session's nudge" { - journal="$(write_journal)" - make_stale "$journal" - marker_dir="${TMPDIR:-/tmp}/session-journal-nudge-$(printf '%s' "$TEST_ROOT" | cksum | awk '{print $1}').d" - - run "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" - [ "$status" -eq 2 ] - [ -d "$marker_dir" ] - - touch -t 202001010000 "$marker_dir" - run "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" - [ "$status" -eq 2 ] -} - -@test "SESSION_JOURNAL_SKIP suppresses the freshness nudge" { - journal="$(write_journal)" - make_stale "$journal" - run env SESSION_JOURNAL_SKIP=1 "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" <<< '{"stop_hook_active":false}' - [ "$status" -eq 0 ] -} - -@test "SESSION_JOURNAL_SKIP does not suppress the secrets guard" { - journal="$(write_journal)" - printf '%s\n' 'token = definitely-secret-value' >> "$journal" - run env SESSION_JOURNAL_SKIP=1 "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" <<< '{"stop_hook_active":false}' - [ "$status" -eq 2 ] - [[ "$output" == *"SECRETS-GUARD"* ]] -} - -@test "a non-numeric freshness window falls back to the default" { - journal="$(write_journal)" - make_stale "$journal" - run env JOURNAL_FRESH_SECS=5m "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" <<< '{"stop_hook_active":false}' - [ "$status" -eq 2 ] - [[ "$output" == *"is not a whole number of seconds"* ]] -} - -@test "identical mtimes select the later filename for the freshness check" { - thin="$(write_journal 2026-01-02-0304-alpha.md docs/plans/work.md 'ACTIVE - alpha')" - detailed="$(write_journal 2026-08-20-1500-charlie.md none 'ACTIVE - charlie')" - tie_mtimes "$thin" "$detailed" - touch -t 202001010000 "$thin" "$detailed" - - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"DETAILED mode"* ]] -} - -@test "the nudge never creates the sessions directory" { - rmdir "$SESSION_JOURNAL_DIR" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [ ! -d "$SESSION_JOURNAL_DIR" ] -} - -# The gate can only assert what git can prove: the journal has uncommitted -# content changes, or it rode along in the most recent commit. A journal that did -# neither, while tracked work piled up, has not recorded that work -- and a bare -# `touch` must not be able to claim otherwise. -@test "a touched but unchanged journal does not satisfy the gate when other work happened" { - setup_git_workspace - journal="$(write_journal)" - commit_workspace "baseline" - printf 'v2\n' > "$TEST_ROOT/src/app.txt" - commit_workspace "work without touching the journal" - printf 'v3\n' > "$TEST_ROOT/src/app.txt" - touch "$journal" - - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"does not record the tracked changes"* ]] -} - -@test "a genuinely edited journal satisfies the gate" { - setup_git_workspace - journal="$(write_journal)" - commit_workspace "baseline" - printf 'v2\n' > "$TEST_ROOT/src/app.txt" - printf -- '- recorded the change\n' >> "$journal" - - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "a clean work tree needs no journal movement" { - setup_git_workspace - write_journal >/dev/null - commit_workspace "baseline" - - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "JOURNAL_REQUIRE_CONTENT_CHANGE=0 restores mtime-only freshness" { - setup_git_workspace - journal="$(write_journal)" - commit_workspace "baseline" - printf 'v2\n' > "$TEST_ROOT/src/app.txt" - touch "$journal" - - run env JOURNAL_REQUIRE_CONTENT_CHANGE=0 "$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh" <<< '{"stop_hook_active":false}' - [ "$status" -eq 0 ] -} - -@test "a gitignored journal falls back to mtime-only freshness" { - setup_git_workspace - journal="$(write_journal)" - printf 'sessions/\n' > "$TEST_ROOT/.gitignore" - commit_workspace "baseline" - printf 'v2\n' > "$TEST_ROOT/src/app.txt" - touch "$journal" - - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "a journal committed alongside the work is not treated as unmoved" { - setup_git_workspace - journal="$(write_journal)" - commit_workspace "baseline" - printf 'v2\n' > "$TEST_ROOT/src/app.txt" - printf -- '- recorded the change\n' >> "$journal" - commit_workspace "work and journal together" - printf 'v3\n' > "$TEST_ROOT/src/app.txt" - - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "untracked scratch files are not work the journal owes an entry for" { - setup_git_workspace - write_journal >/dev/null - commit_workspace "baseline" - printf 'junk' > "$TEST_ROOT/.DS_Store" - - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "a slow or oversized stdin payload cannot stall the hook" { - journal="$(write_journal)" - make_stale "$journal" - start="$(date +%s)" - run bash -c "{ printf '{\n'; i=0; while [ \$i -lt 5000 ]; do printf ' \"pad%s\": 1,\n' \"\$i\"; i=\$((i+1)); done; printf ' \"stop_hook_active\": false\n}\n'; } | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - elapsed=$(( $(date +%s) - start )) - [ "$status" -eq 2 ] - [ "$elapsed" -lt 15 ] -} diff --git a/tests/pointer.bats b/tests/pointer.bats deleted file mode 100644 index 2c555af..0000000 --- a/tests/pointer.bats +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bats - -load test_helper - -setup() { setup_workspace; } -teardown() { teardown_workspace; } - -@test "empty sessions emits valid create instruction JSON" { - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - printf '%s' "$output" | jq -e '.hookSpecificOutput.hookEventName == "SessionStart"' >/dev/null - [[ "$output" == *"No session journal exists"* ]] -} - -@test "newest journal is selected and status plus complete next step are extracted" { - older="$(write_journal 2026-01-02-0304-older.md none 'DONE - old')" - sleep 1 - newer="$(write_journal 2026-01-02-0305-newer.md docs/plans/work.md 'ACTIVE - new')" - touch "$older" "$newer" - touch -t 202001010000 "$older" - - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - printf '%s' "$output" | jq -e . >/dev/null - [[ "$output" == *"2026-01-02-0305-newer.md"* ]] - [[ "$output" == *"ACTIVE - new"* ]] - [[ "$output" == *"Run the next focused test."* ]] - [[ "$output" == *'Preserve quoted text: \"ready\".'* ]] -} - -@test "README is excluded and missing fields are handled" { - printf '# sessions docs\n' > "$SESSION_JOURNAL_DIR/README.md" - printf '# Minimal journal\n' > "$SESSION_JOURNAL_DIR/2026-01-02-0304-minimal.md" - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - printf '%s' "$output" | jq -e . >/dev/null - [[ "$output" == *"Status not recorded"* ]] - [[ "$output" == *"Next step not recorded"* ]] -} - -@test "identical mtimes fall back to the filename instead of keeping the oldest" { - older="$(write_journal 2026-01-02-0304-alpha.md none 'ACTIVE - alpha')" - newer="$(write_journal 2026-08-20-1500-charlie.md none 'ACTIVE - charlie')" - tie_mtimes "$older" "$newer" - - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"2026-08-20-1500-charlie.md"* ]] - [[ "$output" != *"2026-01-02-0304-alpha.md"* ]] -} - -@test "mtime still outranks the filename so an appended journal stays live" { - newer_name="$(write_journal 2026-08-20-1500-charlie.md none 'ACTIVE - charlie')" - sleep 1 - older_name="$(write_journal 2026-01-02-0304-alpha.md none 'ACTIVE - alpha')" - touch "$older_name" - touch -t 202001010000 "$newer_name" - - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"2026-01-02-0304-alpha.md"* ]] -} - -@test "a DONE newest journal yields to an older journal still open for work" { - live="$(write_journal 2026-01-02-0304-live.md none 'ACTIVE - still working')" - sleep 1 - done_journal="$(write_journal 2026-01-02-0305-done.md none 'DONE - shipped')" - touch "$done_journal" - - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - printf '%s' "$output" | jq -e . >/dev/null - [[ "$output" == *"Live session journal"* ]] - [[ "$output" == *"2026-01-02-0304-live.md"* ]] - [[ "$output" == *"2026-01-02-0305-done.md"* ]] - [ -n "$live" ] -} - -@test "a BLOCKED journal counts as open work" { - write_journal 2026-01-02-0304-blocked.md none 'BLOCKED - waiting on review' >/dev/null - sleep 1 - done_journal="$(write_journal 2026-01-02-0305-done.md none 'DONE - shipped')" - touch "$done_journal" - - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"2026-01-02-0304-blocked.md"* ]] -} - -@test "when every journal is DONE the pointer says so instead of replaying its next step" { - write_journal 2026-01-02-0304-done.md none 'DONE - shipped' >/dev/null - - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - printf '%s' "$output" | jq -e . >/dev/null - [[ "$output" == *"finished session"* ]] - [[ "$output" == *"treat this session as new work"* ]] - [[ "$output" != *"Next step for a fresh agent:"* ]] -} - -@test "a title-cased next-step heading still delivers the section" { - journal="$SESSION_JOURNAL_DIR/2026-01-02-0304-drift.md" - printf '**Status:** ACTIVE - drifting\n\n## Live plan pointer\nnone\n\n## Next Step For A Fresh Agent\nRun the drifted command.\n' > "$journal" - - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"Run the drifted command."* ]] - [[ "$output" != *"Next step not recorded"* ]] -} - -@test "TEMPLATE.md is not mistaken for a journal" { - printf '# Session: \n\n**Status:** ACTIVE | DONE | BLOCKED\n' > "$SESSION_JOURNAL_DIR/TEMPLATE.md" - - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"No session journal exists"* ]] -} - -@test "the pointer never creates the sessions directory" { - rmdir "$SESSION_JOURNAL_DIR" - run "$BATS_TEST_DIRNAME/../hooks/session-journal-pointer.sh" - [ "$status" -eq 0 ] - printf '%s' "$output" | jq -e . >/dev/null - [ ! -d "$SESSION_JOURNAL_DIR" ] -} diff --git a/tests/secrets-guard.bats b/tests/secrets-guard.bats deleted file mode 100644 index 571a10d..0000000 --- a/tests/secrets-guard.bats +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bats - -load test_helper - -setup() { setup_workspace; } -teardown() { teardown_workspace; } - -assert_secret_blocks() { - local vector="$1" - journal="$(write_journal)" - printf '%s\n' "$vector" >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"SECRETS-GUARD"* ]] -} - -@test "JWT-like content triggers" { assert_secret_blocks "$(printf '%s%s' 'ey' 'Jabcdefghijklmnopqrstuvwxyz0123456789')"; } -@test "Bearer token triggers" { assert_secret_blocks 'Bearer abcdefghijklmnopQRSTUV'; } -@test "credential assignment triggers" { assert_secret_blocks 'password = hunter2secret'; } -@test "passphrase assignment triggers" { assert_secret_blocks 'passphrase: correct-horse-battery'; } -@test "long hex triggers" { assert_secret_blocks '0123456789abcdef0123456789abcdef01234567'; } -@test "uppercase credential assignment triggers" { assert_secret_blocks 'PASSWORD = synthetic-secret-value'; } -@test "provider-shaped token triggers" { assert_secret_blocks 'AKIAIOSFODNN7EXAMPLE'; } - -@test "truncated ids and key names alone do not trigger" { - journal="$(write_journal)" - printf '%s\n' '6750bd6e…' 'password' 'api_key:' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "a labelled SHA-256 digest on the next line does not trigger" { - journal="$(write_journal)" - printf '%s\n' 'The wheel artifact hashes to' \ - '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "an inline git commit hash does not trigger" { - journal="$(write_journal)" - printf '%s\n' 'Recorded in commit 0123456789abcdef0123456789abcdef01234567 on the release branch.' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "a named token assignment triggers without echoing the value" { - journal="$(write_journal)" - printf '%s\n' 'Digest context nearby: sha-256 receipts are evidence.' \ - 'token = definitely-secret-value' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"SECRETS-GUARD"* ]] - [[ "$output" != *"definitely-secret-value"* ]] -} - -@test "a verified full commit hash can use the line-scoped allowlist" { - journal="$(write_journal)" - printf '%s\n' '0123456789abcdef0123456789abcdef01234567 ' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "allowlist comment suppresses only its line" { - journal="$(write_journal)" - printf '%s\n' 'password = syntheticvalue ' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "guard still blocks when stop_hook_active is true" { - journal="$(write_journal)" - printf '%s\n' 'token = definitely-secret-value' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":true}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"SECRETS-GUARD"* ]] -} - -assert_secret_passes() { - local vector="$1" - journal="$(write_journal)" - printf '%s\n' "$vector" >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "an angle-bracket redaction is accepted" { assert_secret_passes 'password: '; } -@test "an annotated redaction is accepted" { assert_secret_passes 'api_key: '; } -@test "a length-only description is accepted" { assert_secret_passes 'api_key: redacted (32 chars)'; } -@test "a location description is accepted" { assert_secret_passes 'token: stored-in-ci-secrets'; } -@test "an env-var reference is accepted" { assert_secret_passes 'api_key: $STRIPE_API_KEY'; } -@test "a short placeholder is accepted" { assert_secret_passes 'client_secret: placeholder'; } - -@test "the guard names its own escape hatch when it blocks" { - journal="$(write_journal)" - printf '%s\n' 'token = definitely-secret-value' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"journal-secrets-ok"* ]] -} - -@test "prose containing a digest keyword as a substring does not disable hex detection" { - journal="$(write_journal)" - printf '%s\n' 'Avoid rerunning the importer.' \ - '0123456789abcdef0123456789abcdef01234567' \ - 'Android build is green.' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 2 ] - [[ "$output" == *"SECRETS-GUARD"* ]] -} - -@test "a whole-word digest keyword still exempts a receipt" { - journal="$(write_journal)" - printf '%s\n' 'Release tarball SHA-256:' \ - '0123456789abcdef0123456789abcdef01234567' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -# Regressions from the adversarial review of 1.1.0: the value inspector must not -# treat decoration as evidence of a placeholder, and must not stop at the first -# credential on a line. -@test "a double-quoted credential is caught" { assert_secret_blocks 'PASSWORD="S3cr3tP4ssw0rdXyz"'; } -@test "a single-quoted credential is caught" { assert_secret_blocks "password: 'S3cr3tP4ssw0rdXyz'"; } -@test "a backticked credential is caught" { assert_secret_blocks 'password: `S3cr3tP4ssw0rdXyz`'; } -@test "a markdown-emphasised credential is caught" { assert_secret_blocks 'password: **S3cr3tP4ssw0rdXyz**'; } -@test "a credential with shell punctuation is caught" { assert_secret_blocks 'DB password: P@ssw0rd!Secret1'; } -@test "a quoted export assignment is caught" { assert_secret_blocks 'export API_KEY="AIzaSyC9x8v7b6n5m4k3j2h1g0f"'; } - -@test "a second credential is caught even when the first is redacted" { - assert_secret_blocks 'api_key: , password: S3cr3tP4ssw0rdXyz' -} - -@test "digest context still exempts an inflected keyword" { - journal="$(write_journal)" - printf '%s\n' 'Verified with sha256sum:' \ - '0123456789abcdef0123456789abcdef01234567' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "digest context still exempts a committed revision" { - journal="$(write_journal)" - printf '%s\n' 'Committed as' '0123456789abcdef0123456789abcdef01234567' >> "$journal" - run bash -c "printf '%s' '{\"stop_hook_active\":false}' | '$BATS_TEST_DIRNAME/../hooks/session-journal-nudge.sh'" - [ "$status" -eq 0 ] -} - -@test "an elided example value is not mistaken for a credential" { - assert_secret_passes 'api_key: sk-live-...' -} - -@test "a truncated value with a trailing separator is not mistaken for a credential" { - assert_secret_passes 'password: hunter2-' -} diff --git a/tests/test_journal.py b/tests/test_journal.py new file mode 100644 index 0000000..43dd635 --- /dev/null +++ b/tests/test_journal.py @@ -0,0 +1,938 @@ +"""Scenario tests for hooks/session-journal.py: parallel sessions, worktrees, workers, /clear, fork, cwd anchoring, +nudges, secrets, Codex-style input, reconstruct, sweep. + +Run from the repository root: python3 -m unittest discover -s tests -p 'test_*.py' +Every scenario builds a throwaway git repository with a linked worktree; nothing outside the temp dir is touched.""" +import contextlib +import importlib.util +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +J = os.path.join(ROOT, 'hooks', 'session-journal.py') + + +def sh(*a, cwd, env=None): + return subprocess.run(a, cwd=cwd, capture_output=True, text=True, check=True, env=env).stdout + + +class Transcript: + """Minimal Claude Code JSONL writer. Each human prompt gets a prompt_id that Lab.hook passes to Pre/PostToolUse and + Stop, the way Claude Code does.""" + by_path = {} + + def __init__(self, path, t0=None): + self.path = path + open(path, 'w').close() + self.n = 0 + self.t0 = t0 or time.time() + self.prompt_id = None + Transcript.by_path[path] = self + + def _w(self, d): + self.n += 1 + d.setdefault('timestamp', time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime(self.t0 + self.n)) + '.000Z') + with open(self.path, 'a') as f: + f.write(json.dumps(d) + '\n') + + def human(self, text): + self.prompt_id = f'prompt-{self.n + 1}' + self._w({'type': 'user', 'message': {'role': 'user', 'content': text}, 'origin': {'kind': 'human'}}) + + def tool(self, name, **inp): + self._w({'type': 'assistant', 'message': {'role': 'assistant', 'content': [ + {'type': 'tool_use', 'id': f't{self.n}', 'name': name, 'input': inp}]}}) + + def error(self, reason): + """The tool_result recorded for the last tool_use when a hook denied it.""" + self._w({'type': 'user', 'message': {'role': 'user', 'content': [ + {'type': 'tool_result', 'tool_use_id': f't{self.n - 1}', 'is_error': True, 'content': reason}]}}) + + def say(self, text): + self._w({'type': 'assistant', 'message': {'role': 'assistant', 'content': [{'type': 'text', 'text': text}]}}) + + +JOURNAL = """# Session: {title} + +**Started:** 2026-01-08 10:00 UTC +**Last updated:** 2026-01-08 10:00 UTC +**Status:** ACTIVE + +## User intent +{title} + +## Live plan pointer +{pointer} + +## Milestones +- 10:00 - opened + +## Where we are now +- working + +## Next step for a fresh agent +Continue {title}. +""" + + +class Lab(unittest.TestCase): + jrel = 'sessions' + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix='jlab-') + self.repo = os.path.join(self.tmp, 'repo') + os.makedirs(self.repo) + sh('git', 'init', '-q', '-b', 'main', cwd=self.repo) + sh('git', 'config', 'user.email', 't@t.invalid', cwd=self.repo) + sh('git', 'config', 'user.name', 't', cwd=self.repo) + self.jdir = os.path.join(self.repo, self.jrel) + os.makedirs(self.jdir) + open(os.path.join(self.repo, 'README.md'), 'w').write('x') + sh('git', 'add', '.', cwd=self.repo) + sh('git', 'commit', '-qm', 'init', cwd=self.repo) + self.wt = os.path.join(self.tmp, 'wt-feature') + sh('git', 'worktree', 'add', '-q', '-b', 'feature', self.wt, cwd=self.repo) + self.env = {k: v for k, v in os.environ.items() if not k.startswith(('JOURNAL', 'SESSION_JOURNAL', 'CLAUDE', 'CODEX'))} + if self.jrel != 'sessions': + self.env['SESSION_JOURNAL_DIR'] = self.jrel + self.state = os.path.join(self.repo, '.claude/state/session-journal') + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def hook(self, event, sid, cwd=None, env=None, **inp): + tr = Transcript.by_path.get(inp.get('transcript_path')) + if event in ('PreToolUse', 'PostToolUse', 'Stop') and 'prompt_id' not in inp and 'turn_id' not in inp \ + and tr and tr.prompt_id: + inp['prompt_id'] = tr.prompt_id + payload = dict(session_id=sid, cwd=cwd or self.repo, hook_event_name=event, **inp) + e = dict(self.env, **(env or {})) + r = subprocess.run([sys.executable, J, 'hook', event], input=json.dumps(payload), capture_output=True, + text=True, env=e, cwd=cwd or self.repo) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(r.stderr, '') + if event == 'Stop': + self.assertTrue(r.stdout.strip(), 'a Stop hook must always print JSON (Codex rejects empty output)') + out = json.loads(r.stdout) if r.stdout.strip() else None + return out or None + + def ctx_of(self, out): + return out['hookSpecificOutput']['additionalContext'] if out else '' + + def cli(self, *args, env=None): + e = dict(self.env, **(env or {})) + return subprocess.run([sys.executable, J, *args], capture_output=True, text=True, env=e, cwd=self.repo).stdout + + def jfile(self, name, title='x', pointer='none'): + p = os.path.join(self.jdir, name) + open(p, 'w').write(JOURNAL.format(title=title, pointer=pointer)) + return p + + def lease(self, sid): + return json.load(open(os.path.join(self.state, 'leases', sid + '.json'))) + + def write_journal(self, sid, tr, name, cwd=None, text=None, env=None, post=False): + """Agent writes a journal: PreToolUse (may deny) -> write -> PostToolUse. Returns the deny reason (or the + PostToolUse context when post=True).""" + path = os.path.join(cwd or self.repo, self.jrel, name) + ti = {'file_path': path, 'content': text or JOURNAL.format(title=name, pointer='none')} + d = self.hook('PreToolUse', sid, cwd=cwd, env=env, tool_name='Write', tool_input=ti, transcript_path=tr.path) + if d and d['hookSpecificOutput']['permissionDecision'] == 'deny': + tr.tool('Write', **ti) + tr.error(d['hookSpecificOutput']['permissionDecisionReason']) + return d['hookSpecificOutput']['permissionDecisionReason'] + os.makedirs(os.path.dirname(path), exist_ok=True) + open(path, 'w').write(ti['content']) + tr.tool('Write', **ti) + out = self.hook('PostToolUse', sid, cwd=cwd, env=env, tool_name='Write', tool_input=ti, transcript_path=tr.path) + return self.ctx_of(out) if post else None + + def stop(self, sid, tr, **kw): + return self.hook('Stop', sid, transcript_path=tr.path, stop_hook_active=kw.pop('active', False), **kw) + + def edit(self, tr, name='x.py'): + tr.tool('Edit', file_path=os.path.join(self.repo, name), old_string='a', new_string='b') + + +class SessionBinding(Lab): + def test_parallel_sessions_get_their_own_journal_not_newest(self): + """v1 named the newest journal: a parallel session's fresh journal silenced this session's nudge.""" + ta, tb = Transcript(os.path.join(self.tmp, 'a.jsonl')), Transcript(os.path.join(self.tmp, 'b.jsonl')) + self.hook('SessionStart', 'aaaaaaaa-1', source='startup', transcript_path=ta.path) + self.hook('SessionStart', 'bbbbbbbb-1', source='startup', transcript_path=tb.path) + ta.human('fix parser bug') + self.assertIsNone(self.write_journal('aaaaaaaa-1', ta, '2026-01-08-1000-parser-bug.md')) + tb.human('write the user guide') + self.assertIsNone(self.write_journal('bbbbbbbb-1', tb, '2026-01-08-1005-user-guide.md')) + ta.human('now patch it') + self.edit(ta) + ta.tool('Bash', command='git commit -am fix') + d = self.stop('aaaaaaaa-1', ta) + self.assertEqual(d['decision'], 'block') + self.assertIn('2026-01-08-1000-parser-bug.md', d['reason']) + self.assertNotIn('user-guide', d['reason']) + + def test_append_to_journal_owned_by_live_session_is_denied(self): + ta, tb = Transcript(os.path.join(self.tmp, 'a.jsonl')), Transcript(os.path.join(self.tmp, 'b.jsonl')) + self.hook('SessionStart', 'aaaaaaaa-2', source='startup', transcript_path=ta.path) + ta.human('x') + self.write_journal('aaaaaaaa-2', ta, '2026-01-08-1100-search-survey.md') + ctx = self.ctx_of(self.hook('SessionStart', 'bbbbbbbb-2', source='startup', transcript_path=tb.path)) + self.assertIn('IN USE by live session aaaaaaaa', ctx) + tb.human('unrelated question') + reason = self.write_journal('bbbbbbbb-2', tb, '2026-01-08-1100-search-survey.md') + self.assertIn('in use by live session aaaaaaaa', reason) + self.assertIn('Continues', reason) + self.hook('SessionEnd', 'aaaaaaaa-2', reason='prompt_input_exit') + self.assertIsNone(self.write_journal('bbbbbbbb-2', tb, '2026-01-08-1100-search-survey.md')) + + def test_resume_and_compact_rebind_same_journal(self): + t = Transcript(os.path.join(self.tmp, 'c.jsonl')) + self.hook('SessionStart', '44444444-9', source='startup', transcript_path=t.path) + t.human('go') + self.write_journal('44444444-9', t, '2026-01-08-1800-long.md') + for src in ('compact', 'resume'): + ctx = self.ctx_of(self.hook('SessionStart', '44444444-9', source=src, transcript_path=t.path)) + self.assertIn('bound to', ctx) + self.assertIn('2026-01-08-1800-long.md', ctx) + + def test_bash_journal_write_counts_and_binds(self): + t = Transcript(os.path.join(self.tmp, 'b.jsonl')) + self.hook('SessionStart', '33333333-8', source='startup', transcript_path=t.path) + self.jfile('2026-01-08-1700-bash.md') + t.human('go') + self.edit(t, 'a.py') + t.tool('Bash', command=f"cat >> {self.jdir}/2026-01-08-1700-bash.md <<'EOF'\n- 17:05 - patched a.py\nEOF") + t.tool('Bash', command='git commit -am z') + self.assertIsNone(self.stop('33333333-8', t)) + self.assertEqual(self.lease('33333333-8')['journal'], '2026-01-08-1700-bash.md') + + def test_variable_path_bash_write_is_detected(self): + t = Transcript(os.path.join(self.tmp, 'v.jsonl')) + self.hook('SessionStart', '77777777-c', source='startup', transcript_path=t.path) + t.human('implement mul') + self.edit(t, 'm.py') + t.tool('Bash', command='git commit -am mul') + cmd = f'F=$M/{self.jrel}/$(date +%Y)-mul.md; cat > $F < "$TARGET/sessions/-x.md"` was denied to + a worker. A path that visibly points elsewhere, or a variable path to a journal that does not exist here, is + not a write into this journal directory.""" + env = {'JOURNAL_ROLE': 'worker'} + for cmd in (f"python3 - <<'EOF'\ns = 'printf x > \"$TARGET/{self.jrel}/2026-01-02-0304-leak.md\"'\nEOF", + f"cat > /elsewhere/{self.jrel}/2026-01-02-0304-x.md <<'EOF'\nx\nEOF", + f"cd /tmp && echo x > other/{self.jrel}/2026-01-02-0304-x.md"): + self.assertIsNone(self.hook('PreToolUse', 'f9f9f9f9-1', env=env, tool_name='Bash', + tool_input={'command': cmd}, tool_use_id='u'), cmd) + self.jfile('2026-01-02-0304-real.md') + for cmd in (f'echo x >> "$ROOT/{self.jrel}/2026-01-02-0304-real.md"', + f'echo x >> {self.jrel}/2026-01-02-0304-new.md', + f'echo x >> {self.jdir}/2026-01-02-0304-new.md'): + d = self.hook('PreToolUse', 'f9f9f9f9-1', env=env, tool_name='Bash', tool_input={'command': cmd}, + tool_use_id='u') + self.assertEqual(d['hookSpecificOutput']['permissionDecision'], 'deny', cmd) + + def test_live_agent_process_keeps_an_idle_journal_in_use(self): + t = Transcript(os.path.join(self.tmp, 'i.jsonl')) + self.hook('SessionStart', '8d8d8d8d-a', env={'CLAUDE_PID': str(os.getpid())}, source='startup', + transcript_path=t.path) + t.human('x') + self.write_journal('8d8d8d8d-a', t, '2026-01-08-1300-idle.md') + p = os.path.join(self.state, 'leases', '8d8d8d8d-a.json') + l = json.load(open(p)) + l['heartbeat'] = time.time() - 3 * 3600 + json.dump(l, open(p, 'w')) + tb = Transcript(os.path.join(self.tmp, 'ib.jsonl')) + self.assertIn('in use', self.write_journal('8d8d8d8d-b', tb, '2026-01-08-1300-idle.md')) + l['pid'] = 999999 + json.dump(l, open(p, 'w')) + self.assertIsNone(self.write_journal('8d8d8d8d-b', tb, '2026-01-08-1300-idle.md')) + + def test_whoami_and_bound_use_the_callers_session_id(self): + t = Transcript(os.path.join(self.tmp, 'q.jsonl')) + self.hook('SessionStart', '1234abcd-ffff', source='startup', transcript_path=t.path) + t.human('x') + self.write_journal('1234abcd-ffff', t, '2026-01-08-2300-q.md') + env = {'CLAUDE_CODE_SESSION_ID': '1234abcd-ffff'} + self.assertIn('2026-01-08-2300-q.md', self.cli('whoami', env=env)) + self.assertTrue(self.cli('bound', env=env).strip().endswith(f'{self.jrel}/2026-01-08-2300-q.md')) + + def test_bind_cli_resolves_prefix(self): + self.hook('SessionStart', '88888888-dddd', source='startup') + self.jfile('2026-01-08-2100-p.md') + self.cli('bind', '88888888', '2026-01-08-2100-p.md') + leases = sorted(x for x in os.listdir(os.path.join(self.state, 'leases')) if x.endswith('.json')) + self.assertEqual(leases, ['88888888-dddd.json']) + self.assertEqual(self.lease('88888888-dddd')['journal'], '2026-01-08-2100-p.md') + + def test_state_directory_ignores_itself(self): + self.hook('SessionStart', 'abcd0000-1', source='startup') + self.assertEqual(open(os.path.join(self.state, '.gitignore')).read(), '*\n') + self.assertNotIn('.claude', sh('git', 'status', '--porcelain', '--untracked-files=all', cwd=self.repo)) + + +class Nudges(Lab): + def test_readonly_turn_never_nudged_and_marker_does_not_leak(self): + """v1: the loop marker survived the stop_hook_active exit and silenced the NEXT turn.""" + t = Transcript(os.path.join(self.tmp, 'r.jsonl')) + self.hook('SessionStart', '11111111-6', source='startup', transcript_path=t.path) + t.human('hello') + t.say('hi') + self.assertIsNone(self.stop('11111111-6', t)) + t.human('do work') + self.write_journal('11111111-6', t, '2026-01-08-1500-work.md') + t.human('commit it') + t.tool('Bash', command='git commit -am x') + self.assertEqual(self.stop('11111111-6', t)['decision'], 'block') + self.assertIsNone(self.stop('11111111-6', t, active=True)) + t.human('commit more') + t.tool('Bash', command='git commit -am y') + d = self.stop('11111111-6', t) + self.assertIsNotNone(d, 'the previous turn must not suppress this one') + self.assertEqual(d['decision'], 'block') + + def test_debounce_reduces_nudge_loops(self): + env = {'JOURNAL_LANDING_BASH': r'\bdispatch-tool send\b'} + t = Transcript(os.path.join(self.tmp, 'd.jsonl')) + self.hook('SessionStart', '22222222-7', env=env, source='startup', transcript_path=t.path) + t.human('start') + self.write_journal('22222222-7', t, '2026-01-08-1600-coord.md', env=env) + t.human('dispatch worker 1') + t.tool('Bash', command='dispatch-tool send w1 "go"') + self.assertIsNone(self.hook('Stop', '22222222-7', env=env, transcript_path=t.path, stop_hook_active=False), + 'one unjournaled landed turn within 20 min is debounced') + t.human('dispatch worker 2') + t.tool('Bash', command='dispatch-tool send w2 "go"') + d = self.hook('Stop', '22222222-7', env=env, transcript_path=t.path, stop_hook_active=False) + self.assertEqual(d['decision'], 'block', 'the second unjournaled landed turn nudges') + + def test_counter_counts_a_turn_once_across_stop_rounds(self): + t = Transcript(os.path.join(self.tmp, 'n.jsonl')) + self.hook('SessionStart', '5a5a5a5a-1', source='startup', transcript_path=t.path) + t.human('start') + self.write_journal('5a5a5a5a-1', t, '2026-01-08-0800-n.md') + t.human('edit') + self.edit(t, 'n.py') + self.assertIsNone(self.stop('5a5a5a5a-1', t, prompt_id='p-1')) + self.assertIsNone(self.stop('5a5a5a5a-1', t, prompt_id='p-1', active=True), 'another hook blocked; same turn') + self.assertEqual(self.lease('5a5a5a5a-1')['unjournaled_turns'], 1) + + def test_journal_written_in_a_readonly_turn_resets_the_counter(self): + t = Transcript(os.path.join(self.tmp, 'z.jsonl')) + self.hook('SessionStart', '6b6b6b6b-1', source='startup', transcript_path=t.path) + t.human('start') + self.write_journal('6b6b6b6b-1', t, '2026-01-08-0810-z.md') + t.human('edit') + self.edit(t, 'z.py') + self.assertIsNone(self.stop('6b6b6b6b-1', t)) + t.human('note it') + self.write_journal('6b6b6b6b-1', t, '2026-01-08-0810-z.md') + self.assertIsNone(self.stop('6b6b6b6b-1', t)) + t.human('edit again') + self.edit(t, 'z.py') + self.assertIsNone(self.stop('6b6b6b6b-1', t)) + + def test_blocking_stop_hook_rounds_nudge_at_most_once_per_prompt(self): + """Another Stop hook that blocks creates more Stop rounds in the same prompt.""" + t = Transcript(os.path.join(self.tmp, 'g.jsonl')) + sid = '7c7c7c7c-1' + self.hook('SessionStart', sid, source='startup', transcript_path=t.path) + t.human('start') + self.write_journal(sid, t, '2026-01-10-1100-gate.md') + t.human('ship the fix') + self.edit(t, 'fix.py') + t.tool('Bash', command='git commit -am fix') + rounds = [self.stop(sid, t, prompt_id='p-9')] + self.edit(t, 'board.md') + rounds.append(self.stop(sid, t, prompt_id='p-9', active=True)) + rounds.append(self.stop(sid, t, prompt_id='p-9', active=True)) + rounds.append(self.stop(sid, t, prompt_id='p-9')) # a client that does not set stop_hook_active + self.assertEqual(sum(1 for r in rounds if r and r.get('decision') == 'block'), 1, rounds) + self.assertEqual(self.lease(sid)['unjournaled_turns'], 1) + t.human('next') + self.edit(t, 'fix.py') + t.tool('Bash', command='git commit -am fix2') + self.assertEqual(self.stop(sid, t, prompt_id='p-10')['decision'], 'block', 'a new prompt can be nudged') + + def test_thin_journal_gets_a_one_line_nudge_naming_the_plan(self): + plan = 'docs/plans/release.md' + t = Transcript(os.path.join(self.tmp, 'um.jsonl')) + sid = '8d8d8d8d-1' + self.hook('SessionStart', sid, source='startup', transcript_path=t.path) + t.human('release work') + self.write_journal(sid, t, '2026-01-10-1200-release.md', text=JOURNAL.format(title='release', pointer=plan)) + t.human('update the plan') + self.edit(t, plan) + t.tool('Bash', command=f'git commit -m "release plan v2" -- {plan}') + d = self.stop(sid, t, prompt_id='um-1') + self.assertEqual(d['decision'], 'block') + self.assertNotIn('\n', d['reason']) + self.assertIn('THIN', d['reason']) + self.assertIn(plan, d['reason']) + self.assertIn('one milestone line', d['reason']) + t2 = Transcript(os.path.join(self.tmp, 'um2.jsonl')) + sid2 = '8d8d8d8d-2' + self.hook('SessionStart', sid2, source='startup', transcript_path=t2.path) + t2.human('start') + self.write_journal(sid2, t2, '2026-01-10-1210-detail.md') + t2.human('edit') + self.edit(t2) + t2.tool('Bash', command='git commit -am x') + self.assertIn('DETAILED', self.stop(sid2, t2, prompt_id='um-2')['reason']) + + def test_denied_journal_write_does_not_count_as_a_journal_update(self): + ta, tb = Transcript(os.path.join(self.tmp, 'da.jsonl')), Transcript(os.path.join(self.tmp, 'db.jsonl')) + self.hook('SessionStart', '9e9e0000-a', source='startup', transcript_path=ta.path) + self.hook('SessionStart', '9e9e0000-b', source='startup', transcript_path=tb.path) + ta.human('own it') + self.write_journal('9e9e0000-a', ta, '2026-01-10-1300-owned.md') + tb.human('continue owned work') + self.assertIn('in use', self.write_journal('9e9e0000-b', tb, '2026-01-10-1300-owned.md')) + self.edit(tb, 'y.py') + tb.tool('Bash', command='git commit -am y') + d = self.stop('9e9e0000-b', tb, prompt_id='b-1') + self.assertIsNotNone(d) + self.assertIn('no journal yet', d['reason']) + self.assertIsNone(self.lease('9e9e0000-b').get('journal')) + + def test_skip_env_and_skip_cli_silence_nudges(self): + t = Transcript(os.path.join(self.tmp, 's.jsonl')) + self.hook('SessionStart', 'abab1111-1', source='startup', transcript_path=t.path) + t.human('bump a dependency') + self.edit(t, 'deps.txt') + t.tool('Bash', command='git commit -am bump') + self.assertIsNone(self.hook('Stop', 'abab1111-1', env={'SESSION_JOURNAL_SKIP': '1'}, + transcript_path=t.path, stop_hook_active=False)) + self.cli('skip', 'abab1111') + t.human('again') + self.edit(t, 'deps.txt') + t.tool('Bash', command='git commit -am bump2') + self.assertIsNone(self.stop('abab1111-1', t)) + + def test_oversized_journal_is_flagged_once(self): + t = Transcript(os.path.join(self.tmp, 'big.jsonl')) + self.hook('SessionStart', 'b1b1b1b1-1', source='startup', transcript_path=t.path) + t.human('long work') + big = JOURNAL.format(title='big', pointer='none') + ('- milestone line\n' * 3000) + note = self.write_journal('b1b1b1b1-1', t, '2026-01-11-0900-big.md', text=big, post=True) + self.assertIn('Continues', note) + self.assertEqual(self.write_journal('b1b1b1b1-1', t, '2026-01-11-0900-big.md', text=big, post=True), '') + ctx = self.ctx_of(self.hook('SessionStart', 'b2b2b2b2-1', source='startup')) + self.assertIn('IN USE', ctx) + self.hook('SessionEnd', 'b1b1b1b1-1', reason='other', env={'JOURNAL_PAUSE_ON_END': '0'}) + out = self.cli('candidates') + self.assertIn('continue it in a new journal with Continues:', out) + + +class Lifecycle(Lab): + def test_session_end_pauses_and_records_session_then_reconstruct(self): + t = Transcript(os.path.join(self.tmp, '55555555-aa.jsonl')) + self.hook('SessionStart', '55555555-aa', source='startup', transcript_path=t.path) + t.human('investigate the flaky upload test') + self.write_journal('55555555-aa', t, '2026-01-08-1900-flaky-upload.md') + t.tool('Bash', command='git commit -m "fix: retry timeout"') + t.say('Root cause: the retry timeout was shorter than the server timeout.') + self.hook('SessionEnd', '55555555-aa', reason='prompt_input_exit') + text = open(os.path.join(self.jdir, '2026-01-08-1900-flaky-upload.md')).read() + self.assertIn('**Sessions:**', text) + self.assertIn('55555555', text) + self.assertIn('PAUSED', text) + out = self.cli('reconstruct', '2026-01-08-1900-flaky-upload.md') + self.assertIn('investigate the flaky upload test', out) + self.assertIn('retry timeout', out) + p = self.jfile('2026-01-09-0900-flaky-upload-2.md', title='follow-up') + open(p, 'a').write(f'\n**Continues:** {self.jrel}/2026-01-08-1900-flaky-upload.md\n') + self.assertIn('2026-01-09-0900-flaky-upload-2.md', self.cli('reconstruct', '2026-01-08-1900-flaky-upload.md')) + self.assertIn('flaky-upload', self.cli('find', 'flaky', 'upload')) + + def test_pause_on_end_can_be_turned_off(self): + t = Transcript(os.path.join(self.tmp, 'pe.jsonl')) + self.hook('SessionStart', '5c5c5c5c-1', source='startup', transcript_path=t.path) + t.human('x') + self.write_journal('5c5c5c5c-1', t, '2026-01-08-1910-keep.md') + self.hook('SessionEnd', '5c5c5c5c-1', reason='other', env={'JOURNAL_PAUSE_ON_END': '0'}) + text = open(os.path.join(self.jdir, '2026-01-08-1910-keep.md')).read() + self.assertIn('**Status:** ACTIVE', text) + self.assertIn('5c5c5c5c', text) + + def test_clear_starts_new_session_that_is_offered_the_windows_journal(self): + """/clear gives a NEW session id; the window's journal is offered first and is free.""" + env = {'CLAUDE_PID': str(os.getpid())} + ta = Transcript(os.path.join(self.tmp, 'ca.jsonl')) + self.hook('SessionStart', 'c1c1c1c1-a', env=env, source='startup', transcript_path=ta.path) + ta.human('cutover') + self.write_journal('c1c1c1c1-a', ta, '2026-01-08-0900-cutover.md', env=env) + for i in range(6): + self.jfile(f'2026-01-08-10{i}0-other-{i}.md', title=f'other {i}') + self.hook('SessionEnd', 'c1c1c1c1-a', env=env, reason='clear') + text = open(os.path.join(self.jdir, '2026-01-08-0900-cutover.md')).read() + self.assertIn('ACTIVE', text) + self.assertIn('c1c1c1c1', text) + ctx = self.ctx_of(self.hook('SessionStart', 'c2c2c2c2-b', env=env, source='clear')) + self.assertIn('You ran /clear', ctx) + first_candidate = ctx.split('Candidates', 1)[1].splitlines()[1] + self.assertIn('2026-01-08-0900-cutover.md', first_candidate) + tb = Transcript(os.path.join(self.tmp, 'cb.jsonl')) + tb.human('carry on') + self.assertIsNone(self.write_journal('c2c2c2c2-b', tb, '2026-01-08-0900-cutover.md', env=env)) + + def test_fork_does_not_inherit_the_parents_journal(self): + ta = Transcript(os.path.join(self.tmp, 'fa.jsonl'), t0=time.time() - 600) + self.hook('SessionStart', 'f0f0f0f0-a', source='startup', transcript_path=ta.path) + ta.human('survey') + self.write_journal('f0f0f0f0-a', ta, '2026-01-08-1100-survey.md') + tb = Transcript(os.path.join(self.tmp, 'fb.jsonl'), t0=time.time() + 5) + with open(tb.path, 'w') as fh: + fh.write(open(ta.path).read()) # a fork's transcript starts with the parent's history + ctx = self.ctx_of(self.hook('SessionStart', 'f1f1f1f1-b', source='fork', transcript_path=tb.path)) + self.assertIn('fork', ctx) + self.assertIn('2026-01-08-1100-survey.md', ctx) + tb.human('branch off: try the other fix') + self.edit(tb, 'f.py') + d = self.stop('f1f1f1f1-b', tb) + self.assertIn('no journal yet', d['reason']) + self.assertIsNone(self.lease('f1f1f1f1-b')['journal']) + self.assertIn('in use', self.write_journal('f1f1f1f1-b', tb, '2026-01-08-1100-survey.md')) + + def test_health_and_sweep(self): + p = self.jfile('2026-01-01-0900-abandoned.md', title='abandoned') + text = open(p).read() + open(p, 'w').write(text.replace('2026-01-08 10:00 UTC', '2026-01-01 09:00 UTC')) + os.utime(p, (time.time() - 400 * 86400,) * 2) + h = json.loads(self.cli('health')) + self.assertEqual(h['stale_active_7d'], 1) + self.assertEqual(h['version'], open(os.path.join(ROOT, 'VERSION')).read().strip()) + self.assertIn('would pause', self.cli('sweep', '24', '--dry-run')) + self.assertIn('ACTIVE', open(p).read()) + self.cli('sweep', '24') + self.assertIn('PAUSED (auto: idle', open(p).read()) + + def test_sweep_by_git_uses_commit_dates_not_checkout_mtimes(self): + p = self.jfile('2025-08-01-0900-old.md', title='old') + old = '2025-08-01T09:00:00+00:00' + sh('git', 'add', self.jrel, cwd=self.repo) + sh('git', 'commit', '-qm', 'old journal', cwd=self.repo, + env=dict(self.env, GIT_AUTHOR_DATE=old, GIT_COMMITTER_DATE=old)) + text = open(p).read().replace('2026-01-08 10:00 UTC', '2025-08-01 09:00 UTC') + open(p, 'w').write(text) + sh('git', 'commit', '-qam', 'hdr', cwd=self.repo, env=dict(self.env, GIT_AUTHOR_DATE=old, GIT_COMMITTER_DATE=old)) + self.assertIn('sweep: 0 changes', self.cli('sweep', '168'), 'a fresh checkout mtime hides the idle journal') + self.assertIn(f'would pause {self.jrel}/2025-08-01-0900-old.md', self.cli('sweep', '168', '--by-git', '--dry-run')) + self.cli('sweep', '168', '--by-git') + self.assertIn('PAUSED (auto: idle >168h)', open(p).read()) + + def test_sweep_by_git_refuses_when_git_log_fails(self): + self.jfile('2026-01-01-0900-old.md') + sh('git', 'add', self.jrel, cwd=self.repo) + sh('git', 'commit', '-qm', 'j', cwd=self.repo) + spec = importlib.util.spec_from_file_location('journal_under_test', J) + jm = importlib.util.module_from_spec(spec) + spec.loader.exec_module(jm) + real = jm.git + jm.git = lambda *a, **k: '' if a and a[0] == 'log' else real(*a, **k) + old_env = dict(os.environ) + os.environ.clear() + os.environ.update(self.env) + try: + ctx = jm.Ctx(self.repo) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = jm.cmd_sweep(ctx, ['168', '--by-git', '--dry-run']) + finally: + os.environ.clear() + os.environ.update(old_env) + self.assertNotEqual(rc, 0, buf.getvalue()) + self.assertIn('git log', buf.getvalue()) + + +class Candidates(Lab): + def test_candidates_rank_worktree_and_skip_done(self): + def rewrite(path, a, b): + text = open(path).read() + open(path, 'w').write(text.replace(a, b)) + rewrite(self.jfile('2026-01-01-0900-old-done.md', title='old'), 'ACTIVE', 'DONE') + rewrite(self.jfile('2026-01-02-0900-feature-wip.md', title='feature wip'), '**Status:**', + '**Worktree:** wt-feature\n**Status:**') + self.jfile('2026-01-03-0900-main-newer.md', title='main newer') + ctx = self.ctx_of(self.hook('SessionStart', '66666666-b', cwd=self.wt, env={'CLAUDE_PROJECT_DIR': self.wt}, + source='startup')) + self.assertNotIn('old-done', ctx) + self.assertLess(ctx.index('feature-wip'), ctx.index('main-newer')) + + def test_candidates_rank_dirty_journal_first(self): + p = self.jfile('2026-01-03-0900-current.md', title='current') + text = open(p).read() + open(p, 'w').write(text.replace('**Status:** ACTIVE', '**Status:** IN PROGRESS')) + self.jfile('2026-01-04-0900-other.md', title='other') + sh('git', 'add', self.jrel, cwd=self.repo) + sh('git', 'commit', '-qm', 'j', cwd=self.repo) + open(p, 'a').write('\n- more\n') + out = self.cli('candidates') + self.assertIn('current.md [IN PROGRESS]', out) + self.assertLess(out.index('current.md'), out.index('other.md')) + + def test_open_journals_with_free_form_status_are_candidates(self): + for name, status in (('2026-01-08-0000-board.md', 'IN PROGRESS (handoff)'), + ('2026-01-08-0100-wait.md', 'WAITING on review'), + ('2026-01-08-0200-done.md', 'DONE'), ('2026-01-08-0300-complete.md', 'COMPLETE')): + p = self.jfile(name, title=name) + text = open(p).read() + open(p, 'w').write(text.replace('**Status:** ACTIVE', '**Status:** ' + status)) + out = self.cli('candidates') + self.assertIn('board.md [IN PROGRESS (handoff)]', out) + self.assertIn('wait.md [WAITING on review]', out) + self.assertNotIn('done.md', out) + self.assertNotIn('complete.md', out) + + def test_candidates_cli_filters_by_words_and_marks_in_use(self): + plan = 'release-train-plan' + self.jfile('2026-01-01-0900-rt-old.md', title='release train old', pointer=f'docs/{plan}.md') + self.jfile('2026-01-09-0900-unrelated.md', title='user guide') + ta = Transcript(os.path.join(self.tmp, 'ca.jsonl')) + self.hook('SessionStart', 'cdcd0000-a', source='startup', transcript_path=ta.path) + ta.human('release train') + self.write_journal('cdcd0000-a', ta, '2026-01-10-0800-rt-live.md', + text=JOURNAL.format(title='rt live', pointer=f'docs/{plan}.md')) + out = self.cli('candidates', plan, env={'CLAUDE_CODE_SESSION_ID': 'eeee0000-b'}) + self.assertIn('2026-01-01-0900-rt-old.md', out) + self.assertIn('2026-01-10-0800-rt-live.md', out) + self.assertNotIn('unrelated', out) + self.assertRegex(out, r'rt-live\.md \[IN USE by live session cdcd0000') + mine = self.cli('candidates', plan, env={'CLAUDE_CODE_SESSION_ID': 'cdcd0000-a'}) + self.assertTrue(mine.startswith(f'bound: {self.jrel}/2026-01-10-0800-rt-live.md'), mine) + + def test_first_prompt_adds_matching_journals_beyond_the_startup_list(self): + p = self.jfile('2025-12-01-0900-search-index-migration.md', title='search index migration') + os.utime(p, (time.time() - 30 * 86400,) * 2) + for i in range(30): + self.jfile(f'2026-01-0{i % 9 + 1}-09{i:02d}-unrelated-topic-{i}.md', title=f'unrelated {i}') + ctx = self.ctx_of(self.hook('SessionStart', '9e9e9e9e-a', source='startup')) + self.assertNotIn('search-index', ctx) + out = self.ctx_of(self.hook('UserPromptSubmit', '9e9e9e9e-a', prompt='continue the search index migration')) + self.assertIn('2025-12-01-0900-search-index-migration.md', out) + + +class Worktrees(Lab): + def test_worktree_session_writes_journal_in_main_checkout(self): + t = Transcript(os.path.join(self.tmp, 'w.jsonl')) + env = {'CLAUDE_PROJECT_DIR': self.wt} + ctx = self.ctx_of(self.hook('SessionStart', 'cccccccc-3', cwd=self.wt, env=env, source='startup', + transcript_path=t.path)) + self.assertIn(os.path.realpath(self.jdir), ctx.replace(self.repo, os.path.realpath(self.repo))) + self.assertIn("worktree 'wt-feature'", ctx) + self.assertIn('**Worktree:** wt-feature (feature)', ctx) + t.human('feature work') + reason = self.write_journal('cccccccc-3', t, '2026-01-08-1200-feature.md', cwd=self.wt, env=env) + self.assertIn('main checkout', reason) + ti = {'file_path': os.path.join(self.jdir, '2026-01-08-1200-feature.md'), 'content': 'x'} + self.assertIsNone(self.hook('PreToolUse', 'cccccccc-3', cwd=self.wt, env=env, tool_name='Write', tool_input=ti)) + lease = self.lease('cccccccc-3') + self.assertEqual((lease['worktree'], lease['branch']), ('wt-feature', 'feature')) + self.assertFalse(os.path.exists(os.path.join(self.wt, '.claude')), 'no state in the linked worktree') + + def test_pooled_worktree_is_named_by_its_branch(self): + """Pooled worktrees are often checked out as //: the basename says nothing.""" + self.jfile('2026-01-01-0000-old.md', title='old work') + sh('git', 'add', self.jrel, cwd=self.repo) + sh('git', 'commit', '-qm', 'old', cwd=self.repo) + slot = os.path.join(self.tmp, 'pool', '1', 'repo') + os.makedirs(os.path.dirname(slot)) + sh('git', 'worktree', 'add', '-q', '-b', 'w/gap', slot, cwd=self.repo) + env = {'CLAUDE_PROJECT_DIR': slot} + t = Transcript(os.path.join(self.tmp, 'th.jsonl')) + ctx = self.ctx_of(self.hook('SessionStart', 'dede0000-1', cwd=slot, env=env, source='startup', + transcript_path=t.path)) + self.assertIn('**Worktree:** w/gap (w/gap)', ctx) + t.human('implement the gap') + text = JOURNAL.format(title='gap', pointer='none').replace('**Status:** ACTIVE', + '**Status:** ACTIVE\n**Worktree:** w/gap (w/gap)') + self.assertIsNone(self.write_journal('dede0000-1', t, '2026-01-10-0900-gap.md', cwd=self.repo, env=env, text=text)) + self.hook('SessionEnd', 'dede0000-1', cwd=slot, env=env, reason='other') + body = open(os.path.join(self.jdir, '2026-01-10-0900-gap.md')).read() + self.assertIn('dede0000 (w/gap/w/gap', body) + self.assertEqual(sorted(os.listdir(os.path.join(slot, self.jrel))), ['2026-01-01-0000-old.md']) + self.assertFalse(os.path.exists(os.path.join(slot, '.claude'))) + + def test_hooks_anchor_on_project_dir_not_on_cwd(self): + """A hook's cwd follows the agent's `cd`; journals and state must stay with the session's project.""" + other = os.path.join(self.tmp, 'other-repo') + os.makedirs(other) + sh('git', 'init', '-q', '-b', 'main', cwd=other) + env = {'CLAUDE_PROJECT_DIR': self.repo} + t = Transcript(os.path.join(self.tmp, 'o.jsonl')) + self.hook('SessionStart', 'abab0000-1', env=env, source='startup', transcript_path=t.path) + t.human('fix helper') + t.tool('Edit', file_path=os.path.join(other, 'helper.sh'), old_string='a', new_string='b') + t.tool('Bash', command='git commit -am helper') + d = self.hook('Stop', 'abab0000-1', cwd=other, env=env, transcript_path=t.path, stop_hook_active=False) + self.assertIn(self.jdir, d['reason']) + self.hook('PreToolUse', 'abab0000-1', cwd=other, env=env, tool_name='Bash', + tool_input={'command': f'ls {self.jrel}/'}) + self.assertFalse(os.path.exists(os.path.join(other, self.jrel))) + self.assertFalse(os.path.exists(os.path.join(other, '.claude'))) + self.assertTrue(os.path.exists(os.path.join(self.state, 'leases', 'abab0000-1.json'))) + + def test_repo_without_journal_dir_is_left_alone(self): + bare = os.path.join(self.tmp, 'plain') + os.makedirs(bare) + sh('git', 'init', '-q', cwd=bare) + env = {'CLAUDE_PROJECT_DIR': bare} + self.assertIsNone(self.hook('SessionStart', 'acac0000-1', cwd=bare, env=env, source='startup')) + self.assertIsNone(self.hook('Stop', 'acac0000-1', cwd=bare, env=env, stop_hook_active=False)) + self.assertEqual(sorted(os.listdir(bare)), ['.git']) + + def test_journal_file_in_another_repository_is_not_ours(self): + other = os.path.join(self.tmp, 'elsewhere') + os.makedirs(os.path.join(other, self.jrel)) + sh('git', 'init', '-q', cwd=other) + ti = {'file_path': os.path.join(other, self.jrel, '2026-01-08-1200-x.md'), 'content': 'x'} + self.assertIsNone(self.hook('PreToolUse', 'a0a0a0a0-1', tool_name='Write', tool_input=ti)) + + +class Workers(Lab): + def test_worker_results_reach_the_lead_and_find(self): + tl, tw = Transcript(os.path.join(self.tmp, 'l.jsonl')), Transcript(os.path.join(self.tmp, 'w.jsonl')) + self.hook('SessionStart', '99999999-lead', source='startup', transcript_path=tl.path) + tl.human('coordinate') + self.write_journal('99999999-lead', tl, '2026-01-08-2200-coord.md') + env = {'JOURNAL_ROLE': 'worker'} + self.hook('SessionStart', 'aaaa0000-wrk', env=env, source='startup', transcript_path=tw.path) + tw.human('add divide()') + self.edit(tw, 'src/calc.py') + tw.tool('Bash', command='git commit -am "Add divide"') + tw.say('divide() added with ValueError on zero; tests pass.') + self.assertIsNone(self.hook('Stop', 'aaaa0000-wrk', env=env, transcript_path=tw.path, stop_hook_active=False)) + out = self.ctx_of(self.hook('UserPromptSubmit', '99999999-lead', prompt='status?')) + self.assertIn('worker aaaa0000', out) + self.assertIn('divide', out) + self.assertIsNone(self.hook('UserPromptSubmit', '99999999-lead', prompt='again'), 'shown once') + self.assertIn('Worker ledger', self.cli('find', 'divide')) + + def test_dispatch_prompt_worker_is_soft_env_worker_is_hard(self): + rx = {'JOURNAL_WORKER_PROMPT': r'Read \S+task\.md and follow it exactly'} + t = Transcript(os.path.join(self.tmp, 'k.jsonl')) + self.hook('SessionStart', 'dddddddd-4', env=rx, source='startup', transcript_path=t.path) + out = self.ctx_of(self.hook('UserPromptSubmit', 'dddddddd-4', env=rx, + prompt='Read /x/tasks/T1/task.md and follow it exactly.')) + self.assertIn('WORKER', out) + t.human('Read /x/tasks/T1/task.md and follow it exactly.') + self.edit(t, 'guide.md') + t.tool('Bash', command='git commit -am guide') + self.assertIsNone(self.hook('Stop', 'dddddddd-4', env=rx, transcript_path=t.path, stop_hook_active=False)) + self.assertIsNone(self.write_journal('dddddddd-4', t, '2026-01-08-1300-deliverable.md', env=rx), + 'a soft worker may keep the journal its task asks for') + self.hook('SessionStart', 'dddddddd-5', env=rx, source='startup') + self.assertIsNone(self.hook('UserPromptSubmit', 'dddddddd-5', env=rx, + prompt='about /x/tasks/T1/task.md: what changes for the operator?')) + self.assertEqual(self.lease('dddddddd-5')['role'], 'lead') + te = Transcript(os.path.join(self.tmp, 'e.jsonl')) + env = {'JOURNAL_ROLE': 'worker'} + self.assertIn('WORKER', self.ctx_of(self.hook('SessionStart', 'eeeeeeee-4', env=env, source='startup', + transcript_path=te.path))) + te.human('x') + self.assertIn('worker', self.write_journal('eeeeeeee-4', te, '2026-01-08-1301-noise.md', env=env)) + self.cli('role', 'lead', env={'CLAUDE_CODE_SESSION_ID': 'eeeeeeee-4'}) + self.assertIsNone(self.write_journal('eeeeeeee-4', te, '2026-01-08-1301-noise.md', env=env)) + + def test_no_worker_heuristic_without_configuration(self): + self.hook('SessionStart', 'dddd0000-6', source='startup') + self.assertIsNone(self.hook('UserPromptSubmit', 'dddd0000-6', prompt='Read /x/task.md and follow it exactly.')) + self.assertEqual(self.lease('dddd0000-6')['role'], 'lead') + + +class Secrets(Lab): + LEAK = ('api_key = ' + 'sk-' + 'live4eC39HqLyjWDarjtT1zdp7dc') + + def test_credential_write_is_denied_without_echoing_the_value(self): + t = Transcript(os.path.join(self.tmp, 's.jsonl')) + self.hook('SessionStart', 'ffffffff-5', source='startup', transcript_path=t.path) + t.human('storage') + key = 'DefaultEndpointsProtocol=https;AccountName=demo;AccountKey=' + 'A' * 86 + '==' + for body, kind in ((key, 'azure-storage-key'), ('dbPassword=Dr@gon5ly164', 'secret-assignment'), + ('The staging database password is Hunter2Secret!x so', 'password-prose'), + (self.LEAK, 'api-key')): + reason = self.write_journal('ffffffff-5', t, '2026-01-08-1400-store.md', + text=JOURNAL.format(title='b', pointer='none') + body) + self.assertIn('credential', reason or '', body) + self.assertIn(kind, reason) + for fragment in ('AAAA', 'Dr@gon', 'Hunter2', 'live4eC'): + self.assertNotIn(fragment, reason) + self.assertFalse(os.path.exists(os.path.join(self.jdir, '2026-01-08-1400-store.md'))) + + def test_redactions_and_references_are_accepted(self): + t = Transcript(os.path.join(self.tmp, 's.jsonl')) + self.hook('SessionStart', 'fefefefe-5', source='startup', transcript_path=t.path) + t.human('storage') + for benign in ('password: ', 'token: $API_TOKEN', 'client_secret: ${CLIENT_SECRET}', + 'api_key: stored-in-ci-secrets', 'password: redacted (32 chars)', 'secretName: app-secret', + 'ingress.tls.secret=default/wildcard-cert', 'adminPassword: mandatory', + 'Cipher: Secret AES_128_CBC', 'commit 0123456789abcdef0123456789abcdef01234567', + 'sha256 9991e0b2903da4c8f6122b5c3186448b927a5da4deef1fe45271c3793f4ee29c', + 'api_key: sk-live-...', 'Authorization: Bearer ', + self.LEAK + ' '): + self.assertIsNone(self.write_journal('fefefefe-5', t, '2026-01-08-1400-ok.md', + text=JOURNAL.format(title='b', pointer='none') + benign), benign) + + def test_bash_heredoc_with_a_credential_is_denied(self): + cmd = f"cat >> {self.jrel}/2026-01-08-1400-x.md <<'EOF'\n{self.LEAK}\nEOF" + d = self.hook('PreToolUse', 'f1f1f1f1-1', tool_name='Bash', tool_input={'command': cmd}, tool_use_id='u') + self.assertEqual(d['hookSpecificOutput']['permissionDecision'], 'deny') + + def test_warn_mode_is_advisory(self): + env = {'JOURNAL_SECRETS': 'warn'} + t = Transcript(os.path.join(self.tmp, 'w.jsonl')) + self.hook('SessionStart', 'f2f2f2f2-1', env=env, source='startup', transcript_path=t.path) + t.human('x') + note = self.write_journal('f2f2f2f2-1', t, '2026-01-08-1400-w.md', env=env, post=True, + text=JOURNAL.format(title='w', pointer='none') + self.LEAK) + self.assertIn('credential', note) + self.assertNotIn('live4eC', note) + self.assertTrue(os.path.exists(os.path.join(self.jdir, '2026-01-08-1400-w.md'))) + + def test_scan_cli_reports_lines_not_values(self): + self.jfile('2026-01-08-1500-clean.md') + p = self.jfile('2026-01-08-1501-leak.md') + open(p, 'a').write(self.LEAK + '\n') + r = subprocess.run([sys.executable, J, 'scan'], capture_output=True, text=True, env=self.env, cwd=self.repo) + self.assertEqual(r.returncode, 1) + self.assertIn('2026-01-08-1501-leak.md', r.stdout) + self.assertNotIn('clean.md', r.stdout) + self.assertNotIn('live4eC', r.stdout) + + +class Codex(Lab): + """Codex sends turn_id, uses apply_patch with tool_input.command, and has a rollout transcript the Claude parser + does not read: the hooks record their own turn facts.""" + + def codex(self, event, sid, **inp): + inp.setdefault('transcript_path', os.path.join(self.tmp, 'rollout.jsonl')) + return self.hook(event, sid, model='gpt-test', **inp) + + def patch(self, path, body='+x'): + return f'*** Begin Patch\n*** Add File: {path}\n{body}\n*** End Patch\n' + + def test_codex_turn_with_an_edit_and_no_journal_is_nudged_once(self): + open(os.path.join(self.tmp, 'rollout.jsonl'), 'w').write('{"type":"session_meta","payload":{}}\n') + self.codex('SessionStart', 'thr_1', source='startup') + self.codex('UserPromptSubmit', 'thr_1', turn_id='turn-1', prompt='add a feature') + cmd = self.patch('src/feature.py') + self.assertIsNone(self.codex('PreToolUse', 'thr_1', turn_id='turn-1', tool_name='apply_patch', + tool_input={'command': cmd}, tool_use_id='c1')) + self.codex('PostToolUse', 'thr_1', turn_id='turn-1', tool_name='apply_patch', tool_input={'command': cmd}, + tool_use_id='c1') + d = self.codex('Stop', 'thr_1', turn_id='turn-1', stop_hook_active=False) + self.assertEqual(d['decision'], 'block') + self.assertIn('no journal yet', d['reason']) + self.assertIsNone(self.codex('Stop', 'thr_1', turn_id='turn-1', stop_hook_active=True)) + journal = self.patch(f'{self.jrel}/2026-01-12-0900-feature.md', '+' + JOURNAL.replace('\n', '\n+').format( + title='feature', pointer='none')) + self.codex('PreToolUse', 'thr_1', turn_id='turn-1', tool_name='apply_patch', tool_input={'command': journal}, + tool_use_id='c2') + os.makedirs(self.jdir, exist_ok=True) + open(os.path.join(self.jdir, '2026-01-12-0900-feature.md'), 'w').write('x') + self.codex('PostToolUse', 'thr_1', turn_id='turn-1', tool_name='apply_patch', tool_input={'command': journal}, + tool_use_id='c2') + self.assertEqual(self.lease('thr_1')['journal'], '2026-01-12-0900-feature.md') + self.codex('UserPromptSubmit', 'thr_1', turn_id='turn-2', prompt='what does it do?') + self.assertIsNone(self.codex('Stop', 'thr_1', turn_id='turn-2', stop_hook_active=False), 'read-only turn') + self.codex('UserPromptSubmit', 'thr_1', turn_id='turn-3', prompt='commit') + self.codex('PostToolUse', 'thr_1', turn_id='turn-3', tool_name='Bash', tool_input={'command': 'git commit -am f'}, + tool_use_id='c3') + d = self.codex('Stop', 'thr_1', turn_id='turn-3', stop_hook_active=False) + self.assertIn('2026-01-12-0900-feature.md', d['reason']) + + def test_codex_apply_patch_into_an_in_use_journal_is_denied(self): + t = Transcript(os.path.join(self.tmp, 'a.jsonl')) + self.hook('SessionStart', 'aaaa2222-1', source='startup', transcript_path=t.path) + t.human('x') + self.write_journal('aaaa2222-1', t, '2026-01-12-1000-owned.md') + cmd = '*** Begin Patch\n*** Update File: ' + f'{self.jrel}/2026-01-12-1000-owned.md\n@@\n+more\n*** End Patch\n' + d = self.codex('PreToolUse', 'thr_2', turn_id='turn-1', tool_name='apply_patch', tool_input={'command': cmd}, + tool_use_id='c1') + self.assertEqual(d['hookSpecificOutput']['permissionDecision'], 'deny') + self.assertIn('in use', d['hookSpecificOutput']['permissionDecisionReason']) + + def test_codex_session_end_reason_other_pauses(self): + self.codex('SessionStart', 'thr_3', source='startup') + self.codex('UserPromptSubmit', 'thr_3', turn_id='t1', prompt='x') + p = self.jfile('2026-01-12-1100-c.md') + cmd = '*** Begin Patch\n*** Update File: ' + f'{self.jrel}/2026-01-12-1100-c.md\n@@\n+more\n*** End Patch\n' + self.codex('PostToolUse', 'thr_3', turn_id='t1', tool_name='apply_patch', tool_input={'command': cmd}, + tool_use_id='c1') + self.codex('SessionEnd', 'thr_3', reason='other') + text = open(p).read() + self.assertIn('PAUSED', text) + self.assertIn('thr_3', text) + self.assertIn('codex', text) + + +class CustomDirectory(SessionBinding): + """Every session-binding scenario again with the journal directory under docs/.""" + jrel = 'docs/sessions' + + +class Robustness(Lab): + def test_hook_never_crashes_on_garbage(self): + for ev in ('Stop', 'PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'SessionEnd', 'Nope'): + for payload in ('not json', '[]', '{"tool_input": "str"}', ''): + r = subprocess.run([sys.executable, J, 'hook', ev], input=payload, capture_output=True, text=True, + cwd=self.repo, env=self.env) + self.assertEqual(r.returncode, 0, (ev, payload, r.stderr)) + if ev == 'Stop': + json.loads(r.stdout) + + def test_disable_switch(self): + r = subprocess.run([sys.executable, J, 'hook', 'SessionStart'], input='{"session_id":"x"}', capture_output=True, + text=True, cwd=self.repo, env=dict(self.env, SESSION_JOURNAL_DISABLE='1')) + self.assertEqual(r.stdout, '') + self.assertFalse(os.path.exists(self.state)) + + def test_version_matches_the_release(self): + self.assertEqual(self.cli('version').strip(), open(os.path.join(ROOT, 'VERSION')).read().strip()) + + def test_unrelated_tool_calls_take_the_fast_path(self): + r = subprocess.run([sys.executable, J, 'hook', 'PreToolUse'], capture_output=True, text=True, cwd=self.repo, + env=self.env, input=json.dumps({'session_id': 's', 'tool_name': 'Read', + 'tool_input': {'file_path': '/etc/hosts'}})) + self.assertEqual((r.returncode, r.stdout), (0, '')) + self.assertFalse(os.path.exists(self.state), 'no state touched for a call that cannot matter') + + +if __name__ == '__main__': + unittest.main() From 201544f3c071f946e0283eec351f302b15fc0341 Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Wed, 21 Oct 2026 22:49:34 +0530 Subject: [PATCH 08/14] feat: install, migrate and check the 2.0 hooks The installer wires all six events per runtime with an '|| true' safety net and per-event timeouts, removes v1 scripts and hook groups in place, carries a v1 absolute SESSION_JOURNAL_DIR across as a relative path, drops v1-only knobs, installs the agent skill (Claude and Codex), and uninstalls everything it owns. The doctor checks six-event wiring, leftover v1 hooks, Python, that local state stays out of git, and runs the real hooks against a scratch state directory. Templates describe binding, IN USE and PAUSED; CI runs the scenario suite and scans every tracked journal with the hook's own rules. --- .github/workflows/ci.yml | 52 ++---- doctor.sh | 283 ++++++++++++----------------- hooks/claude-settings.snippet.json | 52 +++++- hooks/codex-hooks.snippet.json | 52 +++++- install.sh | 220 +++++++++++++--------- templates/AGENTS.md-section.md | 6 +- templates/CLAUDE.md-section.md | 4 +- templates/session-journal.md | 4 +- templates/sessions-README.md | 27 ++- tests/docs.bats | 52 +++++- tests/doctor.bats | 38 +++- tests/install.bats | 89 +++++++-- tests/test_helper.bash | 4 + 13 files changed, 542 insertions(+), 341 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed0f1bb..a13df41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,53 +25,27 @@ jobs: run: brew install shellcheck - name: Validate shell syntax run: | - bash -n hooks/session-journal-pointer.sh - bash -n hooks/session-journal-nudge.sh bash -n extras/spine-promotion/journal-spine-promotion-nudge.sh bash -n install.sh bash -n doctor.sh - name: Run shellcheck - run: shellcheck hooks/*.sh extras/spine-promotion/*.sh install.sh doctor.sh + run: shellcheck extras/spine-promotion/*.sh install.sh doctor.sh + - name: Run the hook scenario tests + # The runner's python3; the script itself is also checked to parse + # under the oldest supported interpreter's grammar (3.8). + run: | + python3 --version + python3 -c "import ast; ast.parse(open('hooks/session-journal.py').read(), feature_version=(3, 8))" + python3 -m unittest discover -s tests -p 'test_*.py' - name: Run bats suites run: bats tests - - name: Exercise the hooks under the bash the shebang selects - # The hooks advertise bash 3.2 compatibility -- the version macOS still - # ships -- and the selection defect lived precisely in bash 3.2's - # whole-second `-nt` comparison. `bats tests` invokes every script by - # path, so its shebang picks /bin/bash; assert that is really 3.2 here - # rather than claiming coverage a separate job would not add. - if: runner.os == 'macOS' - run: | - /bin/bash --version | head -n 1 - /bin/bash --version | head -n 1 | grep -q 'version 3\.2' \ - || echo "note: /bin/bash is not 3.2 on this runner; the 3.2 claim is unverified here" - /bin/bash -n hooks/session-journal-pointer.sh - /bin/bash -n hooks/session-journal-nudge.sh - name: Self-check this repository's own installation run: ./doctor.sh . - - name: Check every tracked journal with the runtime secrets guard - # The hook deliberately scans only the newest journal, which is the right - # contract at runtime and the wrong one for a repository-wide gate. Older - # journals are history a reader still trusts, so CI walks all of them. - run: | - set -euo pipefail - shopt -s nullglob - status=0 - for journal in sessions/*.md; do - case "$(basename "$journal")" in README.md|TEMPLATE.md) continue ;; esac - scratch="$(mktemp -d)" - mkdir -p "$scratch/sessions" - cp "$journal" "$scratch/sessions/" - if ! printf '%s' '{"stop_hook_active":false}' \ - | CLAUDE_PROJECT_DIR="$scratch" SESSION_JOURNAL_DIR="$scratch/sessions" \ - JOURNAL_FRESH_SECS=999999999 SESSION_JOURNAL_SKIP=1 \ - hooks/session-journal-nudge.sh >/dev/null; then - echo "::error file=$journal::secret-shaped content in $journal" - status=1 - fi - rm -rf "$scratch" - done - exit "$status" + - name: Check every tracked journal for credential shapes + # The hook checks each journal write as it happens; CI re-checks every + # tracked journal, because older journals are history a reader still + # trusts. Line numbers and rule names only, never values. + run: python3 hooks/session-journal.py scan sessions/2*.md secrets: runs-on: ubuntu-latest diff --git a/doctor.sh b/doctor.sh index 52daeeb..4661af4 100755 --- a/doctor.sh +++ b/doctor.sh @@ -1,12 +1,10 @@ #!/bin/bash # Re-runnable self-check for an adopted session-journal installation. # -# ADOPT.md Phase C is a verification matrix the installing agent runs once, at -# adoption time, and then nobody can run again. Hooks that stop firing are -# silent by construction: a SessionStart hook that never runs looks exactly like -# a session with no journal, and a Stop hook that never runs looks exactly like -# a journal that is always fresh. This script turns that one-time ritual into -# something a teammate, a CI job, or a suspicious agent can run at any time. +# Hooks that stop firing are silent by construction: a SessionStart hook that +# never runs looks exactly like a session with no journal, and a Stop hook that +# never runs looks exactly like a journal that is always up to date. This +# script lets a teammate, a CI job, or a suspicious agent check at any time. # # Exit 0 when every check passes, 1 when any check fails. @@ -26,139 +24,96 @@ note() { printf ' %s\n' "$1"; } echo "session-journal doctor — $TARGET" echo -# --- Where are the journals? ------------------------------------------------ - SETTINGS="$TARGET/.claude/settings.json" CODEX_SETTINGS="$TARGET/.codex/hooks.json" -SESSIONS_REL="sessions" -STOP_COMMAND="" -START_COMMAND="" - -# The event matters, not just the presence of the string. A command registered -# under some other event -- or left behind by a hand-edit -- must not be reported -# as a working SessionStart or Stop hook. +SCRIPT="$TARGET/.claude/hooks/session-journal.py" +EVENTS="SessionStart UserPromptSubmit PreToolUse PostToolUse Stop SessionEnd" +HAVE_JQ=false +command -v jq >/dev/null 2>&1 && HAVE_JQ=true + +# The command registered for an event in a settings file, if it runs the script. +# The event matters, not just the presence of the string: a command registered +# under some other event must not be reported as a working hook. read_command() { - local file=$1 event=$2 hook=$3 - [ -e "$file" ] || return 0 - command -v jq >/dev/null 2>&1 || return 0 - jq -r --arg event "$event" --arg hook "$hook" ' + local file=$1 event=$2 + [ -e "$file" ] && [ "$HAVE_JQ" = true ] || return 0 + jq -r --arg event "$event" ' [(.hooks // {})[$event][]? | .hooks[]? | .command // ""] - | map(select(contains($hook))) | first // "" + | map(select(contains("session-journal.py"))) | first // "" ' "$file" 2>/dev/null || printf '' } -for candidate_settings in "$SETTINGS" "$CODEX_SETTINGS"; do - [ -n "$START_COMMAND" ] || START_COMMAND=$(read_command "$candidate_settings" SessionStart session-journal-pointer.sh) - [ -n "$STOP_COMMAND" ] || STOP_COMMAND=$(read_command "$candidate_settings" Stop session-journal-nudge.sh) -done - -# Anchor on the ")/" that closes the command substitution in the generated -# prefix. A greedy ".*/" would swallow every leading path segment and report -# `docs/sessions` as `sessions`, then fail three checks against a healthy repo. -case "$STOP_COMMAND$START_COMMAND" in - *SESSION_JOURNAL_DIR=*) - SESSIONS_REL=$(printf '%s' "$STOP_COMMAND$START_COMMAND" \ - | sed -n 's|.*SESSION_JOURNAL_DIR="[^"]*)/\([^"]*\)".*|\1|p' | head -n 1) - if [ -z "$SESSIONS_REL" ]; then - SESSIONS_REL=$(printf '%s' "$STOP_COMMAND$START_COMMAND" \ - | sed -n 's|.*SESSION_JOURNAL_DIR="\([^"]*\)".*|\1|p' | head -n 1) - SESSIONS_REL=${SESSIONS_REL#"$TARGET"/} - fi - [ -n "$SESSIONS_REL" ] || SESSIONS_REL="sessions" - ;; -esac -SESSIONS_DIR="$TARGET/$SESSIONS_REL" - -# --- Installation ------------------------------------------------------------ +# --- Tools ------------------------------------------------------------------- -if command -v jq >/dev/null 2>&1; then - pass "jq is available" +if [ "$HAVE_JQ" = true ]; then pass "jq is available"; else fail "jq is available" "settings cannot be inspected or merged without it"; fi +if command -v python3 >/dev/null 2>&1 && python3 -c 'import sys; sys.exit(sys.version_info < (3, 8))' 2>/dev/null; then + pass "python3 3.8+ is available ($(python3 -c 'import platform; print(platform.python_version())'))" else - fail "jq is available" "settings cannot be inspected or merged without it" + fail "python3 3.8+ is available" "every hook command ends in '|| true', so without Python the hooks silently do nothing" fi -if [ -e "$SETTINGS" ] || [ -e "$CODEX_SETTINGS" ]; then - for candidate_settings in "$SETTINGS" "$CODEX_SETTINGS"; do - [ -e "$candidate_settings" ] || continue - if ! command -v jq >/dev/null 2>&1; then continue; fi - if jq -e . "$candidate_settings" >/dev/null 2>&1; then - pass "${candidate_settings#"$TARGET"/} is valid JSON" +# --- Wiring ------------------------------------------------------------------ + +SESSIONS_REL="" +RUNTIMES=0 +for candidate_settings in "$SETTINGS" "$CODEX_SETTINGS"; do + [ -e "$candidate_settings" ] || continue + label="${candidate_settings#"$TARGET"/}" + if [ "$HAVE_JQ" = true ] && ! jq -e . "$candidate_settings" >/dev/null 2>&1; then + fail "$label is valid JSON" + continue + fi + wired="" + missing="" + for event in $EVENTS; do + command=$(read_command "$candidate_settings" "$event") + if [ -n "$command" ]; then + wired="$wired $event" + case "$command" in *"|| true"*) ;; *) fail "$label $event command ends in '|| true'" "exit 2 from a missing interpreter would block the agent" ;; esac + if [ -z "$SESSIONS_REL" ]; then + SESSIONS_REL=$(printf '%s' "$command" | sed -nE 's/^(.* )?SESSION_JOURNAL_DIR=("[^"]*"|[^ "]+).*/\2/p' | head -n 1 | tr -d '"') + fi else - fail "${candidate_settings#"$TARGET"/} is valid JSON" + missing="$missing $event" fi done -else - fail "a settings file exists" "no .claude/settings.json and no .codex/hooks.json" -fi - -if [ -n "$START_COMMAND" ]; then - pass "a SessionStart hook is registered" -else - fail "a SessionStart hook is registered" "no command referencing session-journal-pointer.sh" -fi -if [ -n "$STOP_COMMAND" ]; then - pass "a Stop hook is registered" -else - fail "a Stop hook is registered" "no command referencing session-journal-nudge.sh" -fi - -POINTER="$TARGET/.claude/hooks/session-journal-pointer.sh" -NUDGE="$TARGET/.claude/hooks/session-journal-nudge.sh" -for script in "$POINTER" "$NUDGE"; do - label="${script#"$TARGET"/}" - if [ ! -e "$script" ]; then - fail "$label exists" - elif [ ! -x "$script" ]; then - fail "$label is executable" "chmod +x $script" - elif ! bash -n "$script" 2>/dev/null; then - fail "$label parses" + [ -n "$wired" ] || continue + RUNTIMES=$((RUNTIMES + 1)) + if [ -z "$missing" ]; then + pass "$label wires all six journal hooks" else - pass "$label is present, executable, and parses" - stamped=$(sed -n 's|.*session-journal \([0-9][0-9.]*\).*|\1|p' "$script" | head -n 1) - [ -z "$stamped" ] || note "version $stamped" + fail "$label wires all six journal hooks" "missing:$missing (re-run ./install.sh)" + fi + if [ "$HAVE_JQ" = true ] && jq -e '[.hooks // {} | .[]?[]? | .hooks[]? | .command // "" | select(test("session-journal-(pointer|nudge)\\.sh"))] | length > 0' "$candidate_settings" >/dev/null 2>&1; then + fail "$label carries no v1 hooks" "v1 pointer/nudge hooks still registered; re-run ./install.sh to migrate" fi done +[ "$RUNTIMES" -gt 0 ] || fail "a runtime is wired" "no session-journal.py hooks in .claude/settings.json or .codex/hooks.json" +case "$SESSIONS_REL" in *")/"*) SESSIONS_REL=${SESSIONS_REL##*)/} ;; esac +[ -n "$SESSIONS_REL" ] || SESSIONS_REL="sessions" +SESSIONS_DIR="$TARGET/$SESSIONS_REL" -# The registered command must actually resolve. A hook installed into a -# subdirectory of a git repository writes a runtime-relative path that points -# somewhere that does not exist, and then fails silently forever. -if [ -n "$STOP_COMMAND" ]; then - # Resolve the command's path the way the runtime would, without eval: the - # generated command is "$(git rev-parse --show-toplevel ... || pwd)/", - # so recompute that root here and rejoin it with the literal tail. - RUNTIME_ROOT=$(git -C "$TARGET" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$TARGET") - resolved=$(printf '%s' "$STOP_COMMAND" | sed -n 's|.*)\(/[^"]*session-journal-nudge\.sh\)".*|\1|p' | head -n 1) - if [ -n "$resolved" ]; then - resolved="$RUNTIME_ROOT$resolved" - else - resolved=$(printf '%s' "$STOP_COMMAND" | sed -n 's|.*"\(/[^"]*session-journal-nudge\.sh\)".*|\1|p' | head -n 1) - fi - if [ -z "$resolved" ]; then - note "could not parse the Stop command path; skipping resolution check" - elif [ -x "$resolved" ]; then - pass "the registered Stop command resolves to an executable script" - else - fail "the registered Stop command resolves" "$resolved is missing or not executable" - fi +if [ ! -e "$SCRIPT" ]; then + fail ".claude/hooks/session-journal.py exists" +elif ! VERSION_OUT=$(python3 "$SCRIPT" version 2>/dev/null); then + fail ".claude/hooks/session-journal.py runs" "python3 $SCRIPT version failed" +else + pass ".claude/hooks/session-journal.py runs (version $VERSION_OUT)" fi +for legacy in session-journal-pointer.sh session-journal-nudge.sh; do + [ ! -e "$TARGET/.claude/hooks/$legacy" ] || note "v1 script .claude/hooks/$legacy is still on disk; ./install.sh removes it" +done # --- Journal directory ------------------------------------------------------- if [ -d "$SESSIONS_DIR" ]; then pass "journal directory exists ($SESSIONS_REL/)" else - fail "journal directory exists" "$SESSIONS_DIR is missing" -fi -if [ -e "$SESSIONS_DIR/README.md" ]; then - pass "$SESSIONS_REL/README.md exists" -else - fail "$SESSIONS_REL/README.md exists" "agents have no local contract to follow" -fi -if [ -e "$SESSIONS_DIR/TEMPLATE.md" ]; then - pass "$SESSIONS_REL/TEMPLATE.md exists" -else - fail "$SESSIONS_REL/TEMPLATE.md exists" "the hooks tell agents to start from a template that is not installed" + fail "journal directory exists" "$SESSIONS_DIR is missing; the hooks stay inactive without it" fi +for doc in README.md TEMPLATE.md; do + if [ -e "$SESSIONS_DIR/$doc" ]; then pass "$SESSIONS_REL/$doc exists"; else fail "$SESSIONS_REL/$doc exists" "re-run ./install.sh"; fi +done if command -v git >/dev/null 2>&1 && git -C "$TARGET" rev-parse --is-inside-work-tree >/dev/null 2>&1; then if git -C "$TARGET" check-ignore -q "$SESSIONS_DIR" 2>/dev/null; then @@ -168,71 +123,70 @@ if command -v git >/dev/null 2>&1 && git -C "$TARGET" rev-parse --is-inside-work fi if [ -e "$SETTINGS" ] && git -C "$TARGET" check-ignore -q "$SETTINGS" 2>/dev/null; then fail "the hook settings are committed" ".claude/settings.json is gitignored, so only this machine runs the hooks" - elif [ -e "$SETTINGS" ]; then - if [ -n "$(git -C "$TARGET" status --porcelain -- "$SETTINGS" "$POINTER" "$NUDGE" 2>/dev/null)" ]; then - fail "the installation is committed" "commit .claude/ so teammates get the same hooks" - else + else + paths="" + for p in "$SETTINGS" "$CODEX_SETTINGS" "$SCRIPT"; do [ -e "$p" ] && paths="$paths $p"; done + # shellcheck disable=SC2086 + if [ -n "$paths" ] && [ -n "$(git -C "$TARGET" status --porcelain -- $paths 2>/dev/null)" ]; then + fail "the installation is committed" "commit .claude/ (and .codex/) so teammates get the same hooks" + elif [ -n "$paths" ]; then pass "the installation is committed" fi fi + if [ -d "$TARGET/.claude/state/session-journal" ] && \ + [ -n "$(git -C "$TARGET" status --porcelain --untracked-files=all -- .claude/state/session-journal 2>/dev/null)" ]; then + fail "local hook state stays out of git" ".claude/state/session-journal/ shows in git status; restore its .gitignore ('*')" + fi fi # --- Behaviour --------------------------------------------------------------- - -if [ -x "$POINTER" ]; then - if OUTPUT=$(CLAUDE_PROJECT_DIR="$TARGET" SESSION_JOURNAL_DIR="$SESSIONS_DIR" "$POINTER" 2>/dev/null); then - if command -v jq >/dev/null 2>&1 && ! printf '%s' "$OUTPUT" | jq -e '.hookSpecificOutput.hookEventName == "SessionStart"' >/dev/null 2>&1; then - fail "the pointer emits valid SessionStart JSON" - else - pass "the pointer emits valid SessionStart JSON" - case "$OUTPUT" in - *"No session journal exists"*) note "no journal yet: the next substantive session should create one" ;; - *"finished session"*) note "every journal is DONE: the next session starts fresh" ;; - *) note "$(printf '%s' "$OUTPUT" | sed -n 's|.*journal: \([^ .]*\.md\).*|pointing at \1|p' | head -n 1)" ;; - esac - fi +# Run the real hooks against a scratch state directory, so the check neither +# reads nor disturbs the leases of sessions that are running right now. + +if [ -e "$SCRIPT" ] && [ -d "$SESSIONS_DIR" ] && command -v python3 >/dev/null 2>&1; then + SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/session-journal-doctor.XXXXXX") + run_hook() { + printf '%s' "$2" | (cd "$TARGET" && env -u CLAUDE_PROJECT_DIR -u JOURNAL_ROLE -u SESSION_JOURNAL_DISABLE \ + SESSION_JOURNAL_STATE_DIR="$SCRATCH" SESSION_JOURNAL_DIR="$SESSIONS_REL" \ + python3 "$SCRIPT" hook "$1" 2>"$SCRATCH/stderr") + } + OUTPUT=$(run_hook SessionStart "{\"session_id\":\"doctor-check\",\"source\":\"startup\",\"cwd\":\"$TARGET\"}") + if printf '%s' "$OUTPUT" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["hookSpecificOutput"]["hookEventName"]=="SessionStart"' 2>/dev/null; then + pass "SessionStart returns journal context" + printf '%s' "$OUTPUT" | python3 -c ' +import json, sys +ctx = json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"] +rows = [l.strip() for l in ctx.splitlines() if l.strip().startswith("- ")] +print(" %d open candidate journal(s)%s" % (len(rows), (": first " + rows[0][2:].split(" [")[0]) if rows else ""))' else - fail "the pointer runs cleanly" + fail "SessionStart returns journal context" "$(head -c 200 "$SCRATCH/stderr")" fi -fi - -if [ -x "$NUDGE" ]; then - STATUS=0 - printf '%s' '{"stop_hook_active":true}' \ - | CLAUDE_PROJECT_DIR="$TARGET" SESSION_JOURNAL_DIR="$SESSIONS_DIR" "$NUDGE" >/dev/null 2>&1 || STATUS=$? - if [ "$STATUS" -eq 0 ]; then - pass "stop_hook_active suppresses the freshness nudge (no infinite loop)" - elif [ "$STATUS" -eq 2 ]; then - fail "stop_hook_active suppresses the freshness nudge" "the newest journal has secret-looking content; run the Stop hook directly to see the line numbers" + OUTPUT=$(run_hook Stop "{\"session_id\":\"doctor-check\",\"stop_hook_active\":false,\"cwd\":\"$TARGET\"}") + if printf '%s' "$OUTPUT" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then + pass "Stop returns JSON and does not nudge a turn that landed nothing" + case "$OUTPUT" in *'"block"'*) fail "a read-only turn is not nudged" "$OUTPUT" ;; esac + else + fail "Stop returns JSON" "got: $(printf '%s' "$OUTPUT" | head -c 200)" + fi + if SCAN=$(cd "$TARGET" && env -u CLAUDE_PROJECT_DIR SESSION_JOURNAL_STATE_DIR="$SCRATCH" SESSION_JOURNAL_DIR="$SESSIONS_REL" python3 "$SCRIPT" scan 2>&1); then + pass "no journal carries credential-shaped content" else - fail "the nudge runs cleanly" "exit status $STATUS" + fail "no journal carries credential-shaped content" "$(printf '%s' "$SCAN" | head -n 5 | tr '\n' ' ')" fi + rm -rf "${SCRATCH:?}" +fi - # Only meaningful once a journal exists: with an empty directory the nudge - # exits 2 for the missing journal, which is not a secrets finding. - JOURNAL_COUNT=0 - for candidate in "$SESSIONS_DIR"/*.md; do - [ -e "$candidate" ] || continue - case "$(basename "$candidate")" in README.md|TEMPLATE.md) continue ;; esac - JOURNAL_COUNT=$((JOURNAL_COUNT + 1)) - done - if [ "$JOURNAL_COUNT" -eq 0 ]; then - note "no journal to scan for secrets yet" +if [ -d "$TARGET/.claude/state/session-journal" ] && command -v python3 >/dev/null 2>&1 && [ -e "$SCRIPT" ]; then + errors=$(cd "$TARGET" && env -u CLAUDE_PROJECT_DIR SESSION_JOURNAL_DIR="$SESSIONS_REL" python3 "$SCRIPT" health 2>/dev/null \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("hook_errors", 0))' 2>/dev/null || printf '?') + if [ "$errors" = "0" ]; then + pass "the hooks have logged no errors on this machine" else - STATUS=0 - printf '%s' '{"stop_hook_active":false}' \ - | CLAUDE_PROJECT_DIR="$TARGET" SESSION_JOURNAL_DIR="$SESSIONS_DIR" JOURNAL_FRESH_SECS=999999999 SESSION_JOURNAL_SKIP=1 "$NUDGE" >/dev/null 2>&1 || STATUS=$? - if [ "$STATUS" -eq 0 ]; then - pass "the newest journal is free of secret-looking content" - elif [ "$STATUS" -eq 2 ]; then - fail "the newest journal is free of secret-looking content" "run the Stop hook directly for the line numbers" - else - fail "the secrets guard runs cleanly" "exit status $STATUS" - fi + fail "the hooks have logged no errors on this machine" "$errors error(s); see .claude/state/session-journal/events.jsonl" fi fi -# --- Instruction blocks ------------------------------------------------------ +# --- Instruction blocks and skills ------------------------------------------- for instructions in "$TARGET/CLAUDE.md" "$TARGET/AGENTS.md"; do [ -e "$instructions" ] || continue @@ -247,6 +201,9 @@ for instructions in "$TARGET/CLAUDE.md" "$TARGET/AGENTS.md"; do fail "$label carries a balanced session-journal block" "$begins begin marker(s), $ends end marker(s)" fi done +for skill in .claude/skills/session-journal .agents/skills/session-journal; do + [ -e "$TARGET/$skill/SKILL.md" ] && note "skill installed: $skill" +done echo if [ "$FAILURES" -eq 0 ]; then diff --git a/hooks/claude-settings.snippet.json b/hooks/claude-settings.snippet.json index 6d37810..13434e9 100644 --- a/hooks/claude-settings.snippet.json +++ b/hooks/claude-settings.snippet.json @@ -6,7 +6,43 @@ "hooks": [ { "type": "command", - "command": "\"$(git rev-parse --show-toplevel)/.claude/hooks/session-journal-pointer.sh\"" + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook SessionStart || true", + "timeout": 15 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook UserPromptSubmit || true", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook PreToolUse || true", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook PostToolUse || true", + "timeout": 10 } ] } @@ -16,7 +52,19 @@ "hooks": [ { "type": "command", - "command": "\"$(git rev-parse --show-toplevel)/.claude/hooks/session-journal-nudge.sh\"" + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook Stop || true", + "timeout": 30 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook SessionEnd || true", + "timeout": 5 } ] } diff --git a/hooks/codex-hooks.snippet.json b/hooks/codex-hooks.snippet.json index a94a8c0..2c3043a 100644 --- a/hooks/codex-hooks.snippet.json +++ b/hooks/codex-hooks.snippet.json @@ -6,7 +6,43 @@ "hooks": [ { "type": "command", - "command": "\"$(git rev-parse --show-toplevel)/.claude/hooks/session-journal-pointer.sh\"" + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook SessionStart || true", + "timeout": 15 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook UserPromptSubmit || true", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|apply_patch", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook PreToolUse || true", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash|apply_patch", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook PostToolUse || true", + "timeout": 10 } ] } @@ -16,7 +52,19 @@ "hooks": [ { "type": "command", - "command": "\"$(git rev-parse --show-toplevel)/.claude/hooks/session-journal-nudge.sh\"" + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook Stop || true", + "timeout": 30 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook SessionEnd || true", + "timeout": 3 } ] } diff --git a/install.sh b/install.sh index 59496fe..90c74df 100755 --- a/install.sh +++ b/install.sh @@ -9,11 +9,12 @@ Usage: ./install.sh /path/to/target-repo [options] --sessions-dir Journal directory, relative to the target repo (default: sessions) --plan-pointer Default "Live plan pointer" for the generated README (default: none) - --codex Also install Codex hooks and the AGENTS.md block - --no-claude Skip Claude Code hooks and the CLAUDE.md block + --codex Also install Codex hooks, the Codex skill, and the AGENTS.md block + --no-claude Skip Claude Code hooks, the Claude skill, and the CLAUDE.md block + --no-skill Do not install the session-journal agent skill --refresh-docs Regenerate the sessions README, TEMPLATE.md, and the instruction blocks, discarding local adaptations - --uninstall Remove the owned hooks, scripts, and instruction blocks + --uninstall Remove the owned hooks, script, skills, and instruction blocks USAGE exit 64 } @@ -27,6 +28,7 @@ SESSIONS_REL_EXPLICIT=false PLAN_POINTER="none" INSTALL_CODEX=false INSTALL_CLAUDE=true +INSTALL_SKILL=true REFRESH_DOCS=false UNINSTALL=false @@ -45,6 +47,7 @@ while [ "$#" -gt 0 ]; do ;; --codex) INSTALL_CODEX=true; shift ;; --no-claude) INSTALL_CLAUDE=false; shift ;; + --no-skill) INSTALL_SKILL=false; shift ;; --refresh-docs) REFRESH_DOCS=true; shift ;; --uninstall) UNINSTALL=true; shift ;; -h|--help) usage ;; @@ -58,6 +61,7 @@ case "$SESSIONS_REL" in exit 64 ;; esac +SESSIONS_REL=${SESSIONS_REL%/} case "$PLAN_POINTER" in *[\"\\]*|*"|"*|*" @@ -77,6 +81,16 @@ command -v jq >/dev/null 2>&1 || { echo "jq is required to merge hook settings s CLAUDE_SETTINGS="$TARGET/.claude/settings.json" CODEX_SETTINGS="$TARGET/.codex/hooks.json" +SCRIPT_NAME="session-journal.py" +SCRIPT="$TARGET/.claude/hooks/$SCRIPT_NAME" +CLAUDE_SKILL="$TARGET/.claude/skills/session-journal" +CODEX_SKILL="$TARGET/.agents/skills/session-journal" +STATE_DIR="$TARGET/.claude/state/session-journal" +# Every hook group a release of this project has owned. v1 shipped two shell +# scripts; 2.0 replaces both with one Python program. Matching all three names +# is what lets an install double as the v1 -> v2 migration. +OWNED_PATTERN="session-journal\\.py|session-journal-pointer\\.sh|session-journal-nudge\\.sh" +LEGACY_SCRIPTS="session-journal-pointer.sh session-journal-nudge.sh" validate_json_file() { local file=$1 @@ -129,7 +143,7 @@ replace_owned_block() { local tmp="$destination.session-journal.tmp" [ -e "$destination" ] || : > "$destination" strip_owned_block "$destination" > "$tmp" - while [ -s "$tmp" ] && [ "$(tail -c 1 "$tmp" | wc -l | tr -d ' ')" -eq 0 ]; do printf '\n' >> "$tmp"; break; done + if [ -s "$tmp" ] && [ "$(tail -c 1 "$tmp" | wc -l | tr -d ' ')" -eq 0 ]; then printf '\n' >> "$tmp"; fi { printf '%s\n' '' render_template "$template" @@ -150,41 +164,37 @@ remove_owned_block() { [ -s "$destination" ] || rm -f "$destination" } +# Remove every hook group that runs an owned script, under every event. Groups +# that mix an owned command with a foreign one keep the foreign command. remove_hooks() { local file=$1 [ -e "$file" ] || return 0 validate_json_file "$file" local tmp="$file.session-journal.tmp" - jq ' - .hooks = (.hooks // {}) - | .hooks.SessionStart = [.hooks.SessionStart[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-pointer.sh")) | not)] - | .hooks.Stop = [.hooks.Stop[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-nudge.sh")) | not)] + jq --arg owned "$OWNED_PATTERN" ' + if (.hooks | type) == "object" then + .hooks |= with_entries(.value |= ( + [ .[]? | .hooks = [ (.hooks // [])[] | select((.command // "") | test($owned) | not) ] + | select((.hooks | length) > 0) ])) + else . end ' "$file" > "$tmp" mv "$tmp" "$file" } -# ADOPT.md Phase B tells the adopting agent to add environment overrides such as -# JOURNAL_FRESH_SECS to the generated command. Rebuilding the command from -# scratch deleted them on every re-run, so carry any foreign assignments across. -# ADOPT.md Phase B tells the adopting agent to add environment overrides such as -# JOURNAL_FRESH_SECS to the generated command. Rebuilding the command from -# scratch deleted them on every re-run, so carry any foreign assignments across. -# -# Only the leading assignments of the matching command are read -- that is the -# one place a shell env prefix can legally live -- and quoted values are parsed -# rather than stripped. Stripping quotes first turned NAME="value" into NAME=, -# silently dropping exactly the override this exists to preserve, and reading -# the whole command string would let an unrelated hook that merely mentions the -# script name contribute text to a freshly generated command. +# The command currently registered for an event, if it runs an owned script. existing_command() { - local file=$1 event=$2 hook=$3 + local file=$1 event=$2 [ -e "$file" ] || return 0 - jq -r --arg event "$event" --arg hook "$hook" ' + jq -r --arg event "$event" --arg owned "$OWNED_PATTERN" ' [(.hooks // {})[$event][]? | .hooks[]? | .command // ""] - | map(select(contains($hook))) | first // "" + | map(select(test($owned))) | first // "" ' "$file" 2>/dev/null || printf '' } +# Leading NAME=value assignments of a command -- the one place a shell env +# prefix can legally live. Quoted values are parsed rather than stripped. +# `keep` is "all", or "foreign" to drop SESSION_JOURNAL_DIR, which the +# installer owns. v1-only knobs that 2.0 no longer reads are dropped too. parse_env_prefix() { local rest=$1 keep=$2 name value out="" while :; do @@ -203,32 +213,33 @@ parse_env_prefix() { value=${rest%% *} rest=${rest#"$value"} fi - if [ "$keep" = "all" ] || [ "$name" != "SESSION_JOURNAL_DIR" ]; then - out="$out$name=$value " - fi + case "$keep:$name" in + all:*) out="$out$name=$value " ;; + foreign:SESSION_JOURNAL_DIR|foreign:JOURNAL_FRESH_SECS|foreign:JOURNAL_REQUIRE_CONTENT_CHANGE|foreign:JOURNAL_STOP_MARKER_TTL_SECS) ;; + *) out="$out$name=$value " ;; + esac rest=${rest# } done printf '%s' "$out" } -preserved_env() { - local existing - existing=$(existing_command "$1" "$2" "$3") - [ -n "$existing" ] || return 0 - parse_env_prefix "$existing" foreign -} - # The journal directory a previous install wired into this settings file, as a # path relative to the repository root, or empty when the default is in use. +# v1 wrote SESSION_JOURNAL_DIR="$(git rev-parse ...)/"; 2.0 writes the +# relative path, which the script resolves against the main checkout. configured_sessions_dir() { - local existing - existing=$(existing_command "$1" "$2" "$3") - [ -n "$existing" ] || return 0 - # Matched in place rather than split on spaces: the generated value contains a - # command substitution, which contains spaces of its own. - parse_env_prefix "$existing" all \ - | sed -n 's|.*SESSION_JOURNAL_DIR="[^"]*)/\([^"]*\)".*|\1|p' \ - | head -n 1 + local file=$1 event existing value + for event in Stop SessionStart; do + existing=$(existing_command "$file" "$event") + [ -n "$existing" ] || continue + value=$(parse_env_prefix "$existing" all | sed -nE 's/^(.* )?SESSION_JOURNAL_DIR=("[^"]*"|[^ "]+).*/\2/p' | head -n 1) + [ -n "$value" ] || continue + value=${value#\"} + value=${value%\"} + case "$value" in *")/"*) value=${value##*)/} ;; esac + printf '%s' "$value" + return 0 + done } if [ "$UNINSTALL" = true ]; then @@ -237,20 +248,25 @@ if [ "$UNINSTALL" = true ]; then # remove_owned_block below strips both regardless of the runtime flags. sentinels_balanced "$TARGET/CLAUDE.md" || exit 65 sentinels_balanced "$TARGET/AGENTS.md" || exit 65 + validate_json_file "$CLAUDE_SETTINGS" + validate_json_file "$CODEX_SETTINGS" remove_hooks "$CLAUDE_SETTINGS" remove_hooks "$CODEX_SETTINGS" - rm -f "$TARGET/.claude/hooks/session-journal-pointer.sh" "$TARGET/.claude/hooks/session-journal-nudge.sh" + rm -f "$SCRIPT" + for legacy in $LEGACY_SCRIPTS; do rm -f "$TARGET/.claude/hooks/$legacy"; done + rm -rf "${CLAUDE_SKILL:?}" "${CODEX_SKILL:?}" "${STATE_DIR:?}" remove_owned_block "$TARGET/CLAUDE.md" remove_owned_block "$TARGET/AGENTS.md" - echo "Session-journal hooks and instruction blocks removed. Journals, their README, and TEMPLATE.md were kept as project history." + # Directories the install created and that are now empty; anything else stays. + for dir in .claude/hooks .claude/skills .claude/state .agents/skills .agents; do + rmdir "$TARGET/$dir" 2>/dev/null || true + done + echo "Session-journal hooks, script, skills, local state, and instruction blocks removed. Journals, their README, and TEMPLATE.md were kept as project history." exit 0 fi # --------------------------------------------------------------------------- -# Preflight. Every refusal happens here, before the first byte is written: the -# installer used to copy both hooks and merge them live into settings.json and -# THEN exit 65 on an unmatched sentinel, leaving enforcement wired up with no -# instruction block to explain it, plus a stray .tmp file in the repository. +# Preflight. Every refusal happens here, before the first byte is written. # --------------------------------------------------------------------------- [ "$INSTALL_CLAUDE" = false ] || validate_json_file "$CLAUDE_SETTINGS" @@ -258,10 +274,13 @@ fi [ "$INSTALL_CLAUDE" = false ] || sentinels_balanced "$TARGET/CLAUDE.md" || exit 65 [ "$INSTALL_CODEX" = false ] || sentinels_balanced "$TARGET/AGENTS.md" || exit 65 -# The installed hook commands resolve their own root at runtime with -# `git rev-parse --show-toplevel`. Installing into a subdirectory of a git repo -# therefore writes a command that points at a path which does not exist, and the -# hooks fail silently forever. Catch it here instead. +if ! command -v python3 >/dev/null 2>&1; then + echo "Note: python3 is not on PATH here. The hooks need Python 3.8+ wherever the agent runs; without it they do nothing (each command ends in '|| true' so a missing interpreter never blocks the agent)." >&2 +fi + +# The installed hook commands resolve their root at runtime. Installing into a +# subdirectory of a git repository writes a command that points at a path +# which does not exist, and the hooks fail silently forever. Catch it here. if GIT_TOPLEVEL=$(git -C "$TARGET" rev-parse --show-toplevel 2>/dev/null); then GIT_TOPLEVEL=$(cd "$GIT_TOPLEVEL" && pwd -P) if [ "$GIT_TOPLEVEL" != "$TARGET_REAL" ]; then @@ -269,19 +288,24 @@ if GIT_TOPLEVEL=$(git -C "$TARGET" rev-parse --show-toplevel 2>/dev/null); then echo "The installed hooks resolve their root with 'git rev-parse --show-toplevel' at runtime, so they would look for themselves in the wrong place. Install into the repository root instead." >&2 exit 65 fi + # A linked worktree shares its journals with the main checkout; installing + # here would commit hooks on a branch while the journals live elsewhere. + GIT_COMMON=$(git -C "$TARGET" rev-parse --git-common-dir 2>/dev/null || printf '') + case "$GIT_COMMON" in /*) ;; *) GIT_COMMON="$TARGET/$GIT_COMMON" ;; esac + if [ "$(basename "$GIT_COMMON")" = ".git" ] && [ "$(cd "$(dirname "$GIT_COMMON")" && pwd -P)" != "$TARGET_REAL" ]; then + echo "Note: $TARGET is a linked worktree. The hooks keep journals in the main checkout ($(dirname "$GIT_COMMON")); commit this install on the branch the main checkout uses." >&2 + fi else echo "Note: $TARGET is not a git repository. The hook commands fall back to the agent's working directory at runtime; start the agent from the repository root." >&2 fi # A rerun without --sessions-dir must not silently relocate the journal -# directory. Rebuilding the command from the default orphaned every existing -# journal -- the hooks began pointing at an empty `sessions/` while the real -# history sat unreachable in `docs/sessions/` -- and reported success. +# directory: rebuilding the command from the default orphaned every existing +# journal while reporting success. if [ "$SESSIONS_REL_EXPLICIT" = false ]; then for candidate_settings in "$CLAUDE_SETTINGS" "$CODEX_SETTINGS"; do [ -e "$candidate_settings" ] || continue - existing_dir=$(configured_sessions_dir "$candidate_settings" Stop session-journal-nudge.sh) - [ -n "$existing_dir" ] || existing_dir=$(configured_sessions_dir "$candidate_settings" SessionStart session-journal-pointer.sh) + existing_dir=$(configured_sessions_dir "$candidate_settings") if [ -n "$existing_dir" ] && [ "$existing_dir" != "$SESSIONS_REL" ]; then SESSIONS_REL=$existing_dir echo "Reusing the journal directory already configured here: $SESSIONS_REL/ (pass --sessions-dir to move it)." @@ -313,55 +337,75 @@ EVIDENCE_VOCABULARY="commit hashes, test names, issue IDs, and document section mkdir -p "$TARGET/.claude/hooks" "$SESSIONS_ABS" -install_hook_script() { - local name=$1 - sed "s||$VERSION|" "$SOURCE/hooks/$name" > "$TARGET/.claude/hooks/$name" - chmod +x "$TARGET/.claude/hooks/$name" -} -install_hook_script session-journal-pointer.sh -install_hook_script session-journal-nudge.sh +cp "$SOURCE/hooks/$SCRIPT_NAME" "$SCRIPT" +chmod +x "$SCRIPT" +for legacy in $LEGACY_SCRIPTS; do + if [ -e "$TARGET/.claude/hooks/$legacy" ]; then + rm -f "$TARGET/.claude/hooks/$legacy" + echo "Removed the v1 hook script .claude/hooks/$legacy (replaced by $SCRIPT_NAME)." + fi +done -# The sessions README is the adopting agent's artifact: ADOPT.md spends its -# question budget adapting it to the repository. Regenerating it on every run -# made the only available upgrade path silently revert that work. +# The sessions README is the adopting agent's artifact: a rerun must not revert +# the adaptation work ADOPT.md asks for. if [ -e "$SESSIONS_ABS/README.md" ] && [ "$REFRESH_DOCS" = false ]; then echo "Kept the existing $SESSIONS_REL/README.md (re-run with --refresh-docs to regenerate it)." else render_template "$SOURCE/templates/sessions-README.md" > "$SESSIONS_ABS/README.md" fi - -# Ship the journal shape itself. Without this, both the pointer hook and the -# generated README told a fresh agent to start from a template that the -# installer never placed, so every first journal was written from memory. if [ -e "$SESSIONS_ABS/TEMPLATE.md" ] && [ "$REFRESH_DOCS" = false ]; then echo "Kept the existing $SESSIONS_REL/TEMPLATE.md (re-run with --refresh-docs to regenerate it)." else render_template "$SOURCE/templates/session-journal.md" > "$SESSIONS_ABS/TEMPLATE.md" fi -RUNTIME_ROOT="\$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +install_skill() { + local source=$1 destination=$2 + [ "$INSTALL_SKILL" = true ] || return 0 + rm -rf "${destination:?}" + mkdir -p "$(dirname "$destination")" + cp -R "$source" "$destination" +} + +# Claude Code exports CLAUDE_PROJECT_DIR; Codex does not, and runs hooks in the +# session's working directory, where git finds the root. +RUNTIME_ROOT="\${CLAUDE_PROJECT_DIR:-\$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" GENERATED_PREFIX="" -[ "$SESSIONS_REL" = "sessions" ] || GENERATED_PREFIX="SESSION_JOURNAL_DIR=\"$RUNTIME_ROOT/$SESSIONS_REL\" " +[ "$SESSIONS_REL" = "sessions" ] || GENERATED_PREFIX="SESSION_JOURNAL_DIR=$SESSIONS_REL " merge_hooks() { - local file=$1 runtime=$2 start_matcher="*" - [ "$runtime" = "codex" ] && start_matcher="startup|resume|clear|compact" + local file=$1 runtime=$2 groups="[]" event matcher timeout env command mkdir -p "$(dirname "$file")" [ -e "$file" ] || printf '%s\n' '{}' > "$file" - local pointer_env nudge_env pointer_command nudge_command - pointer_env=$(preserved_env "$file" SessionStart session-journal-pointer.sh) - nudge_env=$(preserved_env "$file" Stop session-journal-nudge.sh) - [ -z "$pointer_env" ] || echo "Preserved environment overrides on the SessionStart command: $pointer_env" - [ -z "$nudge_env" ] || echo "Preserved environment overrides on the Stop command: $nudge_env" - pointer_command="${pointer_env}${GENERATED_PREFIX}\"$RUNTIME_ROOT/.claude/hooks/session-journal-pointer.sh\"" - nudge_command="${nudge_env}${GENERATED_PREFIX}\"$RUNTIME_ROOT/.claude/hooks/session-journal-nudge.sh\"" + for event in SessionStart UserPromptSubmit PreToolUse PostToolUse Stop SessionEnd; do + matcher="" + timeout=10 + case "$runtime:$event" in + claude:SessionStart) matcher="*"; timeout=15 ;; + codex:SessionStart) matcher="startup|resume|clear|compact"; timeout=15 ;; + claude:PreToolUse|claude:PostToolUse) matcher="Edit|Write|MultiEdit|NotebookEdit|Bash" ;; + codex:PreToolUse|codex:PostToolUse) matcher="Bash|apply_patch" ;; + *:Stop) timeout=30 ;; + claude:SessionEnd) timeout=5 ;; + codex:SessionEnd) timeout=3 ;; + esac + env=$(parse_env_prefix "$(existing_command "$file" "$event")" foreign) + [ -z "$env" ] || echo "Preserved environment overrides on the $event command: $env" + # `|| true`: a missing interpreter or script exits 127 or 2, and exit 2 + # from a hook blocks the tool call or the turn. A journal hook must never. + command="${env}${GENERATED_PREFIX}python3 \"$RUNTIME_ROOT/.claude/hooks/$SCRIPT_NAME\" hook $event || true" + groups=$(jq -c --arg event "$event" --arg matcher "$matcher" --arg command "$command" --argjson timeout "$timeout" ' + . + [{event: $event, group: ((if $matcher == "" then {} else {matcher: $matcher} end) + + {hooks: [{type: "command", command: $command, timeout: $timeout}]})}] + ' <<< "$groups") + done + remove_hooks "$file" local tmp="$file.session-journal.tmp" - jq --arg pointer "$pointer_command" --arg nudge "$nudge_command" --arg matcher "$start_matcher" ' - .hooks = (.hooks // {}) - | .hooks.SessionStart = ([.hooks.SessionStart[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-pointer.sh")) | not)] + [{matcher:$matcher,hooks:[{type:"command",command:$pointer}]}]) - | .hooks.Stop = ([.hooks.Stop[]? | select((.hooks // [] | map(.command // "") | join(" ") | contains("session-journal-nudge.sh")) | not)] + [{hooks:[{type:"command",command:$nudge}]}]) + jq --argjson groups "$groups" ' + .hooks = ((.hooks // {}) | if type == "object" then . else {} end) + | reduce $groups[] as $g (.; .hooks[$g.event] = ((.hooks[$g.event] // []) + [$g.group])) ' "$file" > "$tmp" mv "$tmp" "$file" } @@ -369,13 +413,15 @@ merge_hooks() { if [ "$INSTALL_CLAUDE" = true ]; then merge_hooks "$CLAUDE_SETTINGS" claude replace_owned_block "$TARGET/CLAUDE.md" "$SOURCE/templates/CLAUDE.md-section.md" CLAUDE.md + install_skill "$SOURCE/skills/session-journal" "$CLAUDE_SKILL" fi if [ "$INSTALL_CODEX" = true ]; then merge_hooks "$CODEX_SETTINGS" codex replace_owned_block "$TARGET/AGENTS.md" "$SOURCE/templates/AGENTS.md-section.md" AGENTS.md + install_skill "$SOURCE/codex/skills/session-journal" "$CODEX_SKILL" fi -echo "Session journal $VERSION installed at $SESSIONS_REL/. Run the verification steps in ADOPT.md Phase C, or ./doctor.sh $TARGET, before relying on it." +echo "Session journal $VERSION installed at $SESSIONS_REL/. Run ./doctor.sh $TARGET before relying on it, then commit .claude/$( [ "$INSTALL_CODEX" = true ] && printf ', .codex/, .agents/'), the instruction blocks, and $SESSIONS_REL/." if [ "$INSTALL_CODEX" = true ]; then echo "Codex: trust this project and review the new hook definitions with /hooks before expecting them to run." fi diff --git a/templates/AGENTS.md-section.md b/templates/AGENTS.md-section.md index cbfcb72..5ab12c3 100644 --- a/templates/AGENTS.md-section.md +++ b/templates/AGENTS.md-section.md @@ -1,9 +1,9 @@ ## Session-journal discipline -For substantive work, keep one journal in `{{SESSIONS_DIR}}/` using `YYYY-MM-DD-HHMM-.md`. Read the newest journal before resuming work. Continue the same task in the same file; create a new file for unrelated work. +For substantive work, keep one journal in `{{SESSIONS_DIR}}/` using `YYYY-MM-DD-HHMM-.md`, started from `{{SESSIONS_DIR}}/TEMPLATE.md`. The SessionStart hook names the journal this session is bound to, or lists candidates: continue the one the task really continues, never one marked IN USE (start your own with `**Continues:**` instead), and never pick a journal by newest name or date. Search older work with `python3 .claude/hooks/session-journal.py find `. -Use THIN mode when `## Live plan pointer` names a governing artifact, and DETAILED mode when it is `none`. Before ending a resumable turn, update `**Last updated:**`, `**Status:**`, evidence, current state, and `## Next step for a fresh agent`. Never put secrets or credential fragments in a journal. Follow `{{SESSIONS_DIR}}/README.md`. +Journal only when something lands: a commit, a meaningful edit, a dispatch, a verified result, or a decision. Use THIN mode when `## Live plan pointer` names a governing artifact, and DETAILED mode when it is `none`. When you update, refresh `**Last updated:**`, `**Status:**`, evidence, current state, and `## Next step for a fresh agent`. Never put secrets or credential fragments in a journal. Workers started with `JOURNAL_ROLE=worker` report to their lead and write no journal. Follow `{{SESSIONS_DIR}}/README.md` and the `session-journal` skill. -Runtime fallback: if this agent host does not run Stop hooks, perform the same freshness and secrets check manually before every final response. +Runtime fallback: if this agent host does not run the hooks, pick the journal with `python3 .claude/hooks/session-journal.py candidates`, and before a final response that landed work, update the journal and run `python3 .claude/hooks/session-journal.py scan`. If both CLAUDE.md and AGENTS.md carry this block, keep their session-journal rules synchronized. diff --git a/templates/CLAUDE.md-section.md b/templates/CLAUDE.md-section.md index a019f85..cc4eed3 100644 --- a/templates/CLAUDE.md-section.md +++ b/templates/CLAUDE.md-section.md @@ -1,7 +1,7 @@ ## Session-journal discipline -For substantive work, keep one journal in `{{SESSIONS_DIR}}/` using `YYYY-MM-DD-HHMM-.md`. Read the newest journal before resuming work. Continue the same task in the same file; create a new file for unrelated work. +For substantive work, keep one journal in `{{SESSIONS_DIR}}/` using `YYYY-MM-DD-HHMM-.md`, started from `{{SESSIONS_DIR}}/TEMPLATE.md`. The SessionStart hook names the journal this session is bound to, or lists candidates: continue the one the task really continues, never one marked IN USE (start your own with `**Continues:**` instead), and never pick a journal by newest name or date. Search older work with `python3 .claude/hooks/session-journal.py find `. -Use THIN mode when `## Live plan pointer` names a governing artifact, and DETAILED mode when it is `none`. Before ending a resumable turn, update `**Last updated:**`, `**Status:**`, evidence, current state, and `## Next step for a fresh agent`. Never put secrets or credential fragments in a journal. Follow `{{SESSIONS_DIR}}/README.md`. +Journal only when something lands: a commit, a meaningful edit, a dispatch, a verified result, or a decision. Use THIN mode when `## Live plan pointer` names a governing artifact, and DETAILED mode when it is `none`. When you update, refresh `**Last updated:**`, `**Status:**`, evidence, current state, and `## Next step for a fresh agent`. Never put secrets or credential fragments in a journal. Workers started with `JOURNAL_ROLE=worker` report to their lead and write no journal. Follow `{{SESSIONS_DIR}}/README.md` and the `session-journal` skill. If both CLAUDE.md and AGENTS.md carry this block, keep their session-journal rules synchronized. diff --git a/templates/session-journal.md b/templates/session-journal.md index dfb984b..2c3540a 100644 --- a/templates/session-journal.md +++ b/templates/session-journal.md @@ -2,7 +2,9 @@ **Started:** YYYY-MM-DD HH:MM {{TIMEZONE}} **Last updated:** YYYY-MM-DD HH:MM {{TIMEZONE}} -**Status:** ACTIVE | DONE | BLOCKED — one current-state line +**Status:** ACTIVE | PAUSED | BLOCKED | DONE — one current-state line +**Worktree:** () (only when written from a linked worktree; omit otherwise) +**Continues:** {{SESSIONS_DIR}}/.md (omit when this is new work) ## User intent diff --git a/templates/sessions-README.md b/templates/sessions-README.md index b3370e7..dc08625 100644 --- a/templates/sessions-README.md +++ b/templates/sessions-README.md @@ -1,6 +1,6 @@ # Session journals -This repository keeps one markdown journal per substantive working session in `{{SESSIONS_DIR}}/`. Journals are written for the next agent: they preserve continuity without replaying a conversation. +This repository keeps one markdown journal per piece of substantive work in `{{SESSIONS_DIR}}/`. Journals are written for the next agent: they preserve continuity without replaying a conversation. ## Local defaults @@ -13,6 +13,16 @@ This repository keeps one markdown journal per substantive working session in `{ Use `YYYY-MM-DD-HHMM-.md` with the session-start time. Start from `{{SESSIONS_DIR}}/TEMPLATE.md`. +## Which journal + +Hooks bind each journal to the session that writes it (`.claude/hooks/session-journal.py`, state in `.claude/state/session-journal/`, which never leaves this machine). + +- At start, the hook names the journal this session is bound to, or lists up to five open candidates ranked by worktree, uncommitted edits, status, and recent agent writes. +- After the first prompt, continue the candidate the task really continues, or create a new journal. Your first write binds it. +- A journal marked **IN USE** belongs to another live session: writes into it are denied. Start your own journal with `**Continues:** {{SESSIONS_DIR}}/.md`. +- Never pick a journal by newest name or date. Use `python3 .claude/hooks/session-journal.py candidates ` or `find `; `reconstruct ` shows the sessions that wrote one. +- Sessions in a linked worktree still write here, in the main checkout, and add `**Worktree:** ()`. + ## THIN and DETAILED modes - **THIN:** `## Live plan pointer` names a governing plan, specification, tracker, ADR, or runbook. Record one-line outcomes and evidence pointers; never duplicate the governing document. @@ -20,21 +30,22 @@ Use `YYYY-MM-DD-HHMM-.md` with the session-start time. Start ## Lifecycle +- Journal only when something lands: a commit, a meaningful edit, a dispatch, a verified result, or a decision. A turn that only read or answered changes nothing here, and the Stop hook does not ask for it. - Same work continuing across days: append to the same journal and update its header. -- Unrelated work: create a new journal. -- After context compaction: read the newest journal and its live plan before acting, then refresh the journal if the compaction changed the next step. +- Unrelated work, or a journal past about 40 KB: create a new journal; link it with `**Continues:**`. +- After context compaction: read the bound journal and its live plan before acting. - Keep `**Status:**` to one line; move state history to `## Change log`. -- Put updates in their proper sections, not at the end of the file. -- Set `**Status:** DONE` when the work is finished. A DONE journal is treated as background, not as the live frontier, so the next session starts clean instead of inheriting a finished next step. +- Set `**Status:** DONE` when the work is finished. A session that ends with the journal still `ACTIVE` marks it `PAUSED`; the `**Sessions:**` line is maintained by the hook. +- Workers started with `JOURNAL_ROLE=worker` never write journals; their lead records the outcome. ## Sessions that need no journal -A typo fix, a dependency bump, or a question answered without changing anything does not need a journal. Set `SESSION_JOURNAL_SKIP=1` for that session instead of writing a journal about nothing or working around the Stop hook. The secrets guard still runs. +A typo fix, a dependency bump, or a question answered without changing anything does not need a journal. Start the agent with `SESSION_JOURNAL_SKIP=1`, or run `python3 .claude/hooks/session-journal.py skip`, instead of writing a journal about nothing. ## Content contract Allowed: concise milestones, decisions, verification evidence, commit references, issue IDs, and artifact pointers. -Prefer evidence that can be re-run over a number captured at write time. `bats tests — 41 checks, see CI run ` can be checked by the next reader; `38 of 38 pass` becomes false the moment the suite grows, and the next agent will believe it and repeat it. +Prefer evidence that can be re-run over a number captured at write time: name the command, the test, or the CI run. -Never include credentials, credential fragments, private tokens, large logs, duplicated plan prose, or speculation presented as fact. The Stop hook scans the newest journal for common secret shapes. Redact a value to a key name, an env-var reference, or a placeholder such as ``. A remaining false positive may carry `` only after a human verifies that the line contains no credential value. +Never include credentials, credential fragments, private tokens, customer or personal data, large logs, duplicated plan prose, or speculation presented as fact. The PreToolUse hook denies a journal write that looks like it carries a credential and names only the line and rule, never the value. Redact to a key name, an env-var reference, or ``. A remaining false positive may carry `` only after a human verifies that the line contains no credential value. diff --git a/tests/docs.bats b/tests/docs.bats index e89c24b..5e0f37e 100644 --- a/tests/docs.bats +++ b/tests/docs.bats @@ -21,18 +21,52 @@ repo() { printf '%s' "$BATS_TEST_DIRNAME/.."; } grep -Fq "## $version" "$(repo)/CHANGELOG.md" } -@test "every environment variable the hooks read is documented" { - for name in JOURNAL_FRESH_SECS SESSION_JOURNAL_DIR SESSION_JOURNAL_SKIP \ - JOURNAL_REQUIRE_CONTENT_CHANGE JOURNAL_STOP_MARKER_TTL_SECS; do +@test "the verification record's unittest count matches the suite" { + claimed="$(sed -n 's|.*\([0-9]*\).*|\1|p' "$(repo)/docs/verification.md")" + [ -n "$claimed" ] + actual="$(grep -c '^ def test_' "$(repo)/tests/test_journal.py")" + [ "$claimed" -eq "$actual" ] +} + +@test "every environment variable the hook reads is documented" { + names="$(grep -oE "environ\.get\('[A-Z_]+'|env_float\('[A-Z_]+'" "$(repo)/hooks/session-journal.py" \ + | grep -oE "'[A-Z_]+'" | tr -d "'" | sort -u)" + [ -n "$names" ] + for name in $names; do + case "$name" in CLAUDE_*|CODEX_*) continue ;; esac grep -Fq "$name" "$(repo)/README.md" || { - echo "$name is read by a hook but never documented in README.md" + echo "$name is read by the hook but never documented in README.md" return 1 } done } +@test "the script reports the release VERSION" { + [ "$(python3 "$(repo)/hooks/session-journal.py" version)" = "$(cat "$(repo)/VERSION")" ] +} + +@test "the Codex skill mirrors the Claude skill and carries openai.yaml" { + cmp "$(repo)/skills/session-journal/SKILL.md" "$(repo)/codex/skills/session-journal/SKILL.md" + grep -q '^interface:' "$(repo)/codex/skills/session-journal/agents/openai.yaml" + grep -q '^name: session-journal$' "$(repo)/skills/session-journal/SKILL.md" + grep -q '^description: ' "$(repo)/skills/session-journal/SKILL.md" +} + +@test "ADOPT.md routes the adopting agent through the skill" { + grep -Fq 'skills/session-journal/SKILL.md' "$(repo)/ADOPT.md" +} + +@test "the hook snippets match what the installer generates" { + target="$(mktemp -d "${TMPDIR:-/tmp}/sj-snippet.XXXXXX")" + git -C "$target" init -q + "$(repo)/install.sh" "$target" --codex --no-skill >/dev/null + diff <(jq -S '{hooks}' "$target/.claude/settings.json") <(jq -S . "$(repo)/hooks/claude-settings.snippet.json") + diff <(jq -S '{hooks}' "$target/.codex/hooks.json") <(jq -S . "$(repo)/hooks/codex-hooks.snippet.json") + rm -rf "${target:?}" +} + @test "every installer flag is documented in the README" { - for flag in --sessions-dir --plan-pointer --codex --no-claude --refresh-docs --uninstall; do + for flag in --sessions-dir --plan-pointer --codex --no-claude --no-skill --refresh-docs --uninstall; do grep -Fq -- "$flag" "$(repo)/README.md" || { echo "$flag is accepted by install.sh but never documented in README.md" return 1 @@ -45,7 +79,9 @@ repo() { printf '%s' "$BATS_TEST_DIRNAME/.."; } grep -Fq '## Live plan pointer' "$(repo)/templates/session-journal.md" } -@test "the spec and the pointer hook agree that DONE is not the live frontier" { - grep -Fq 'DONE' "$(repo)/spec/session-journal-spec.md" - grep -Fq 'ACTIVE|BLOCKED' "$(repo)/hooks/session-journal-pointer.sh" +@test "the spec and the hook agree on the closed statuses" { + for status in DONE COMPLETE COMPLETED DELIVERED CLOSED SUPERSEDED ABANDONED; do + grep -Fq "'$status'" "$(repo)/hooks/session-journal.py" + grep -Fq "\`$status\`" "$(repo)/spec/session-journal-spec.md" + done } diff --git a/tests/doctor.bats b/tests/doctor.bats index 0b706b0..b362fb1 100644 --- a/tests/doctor.bats +++ b/tests/doctor.bats @@ -29,18 +29,17 @@ commit_target() { @test "a missing hook script is reported" { "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null commit_target - rm -f "$TARGET/.claude/hooks/session-journal-nudge.sh" + rm -f "$TARGET/.claude/hooks/session-journal.py" run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" [ "$status" -eq 1 ] [[ "$output" == *"FAIL"* ]] - [[ "$output" == *"session-journal-nudge.sh exists"* ]] + [[ "$output" == *"session-journal.py exists"* ]] } @test "an uninstalled repository is reported rather than passing quietly" { run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" [ "$status" -eq 1 ] - [[ "$output" == *"a SessionStart hook is registered"* ]] - [[ "$output" == *"a Stop hook is registered"* ]] + [[ "$output" == *"a runtime is wired"* ]] } @test "hooks that git will never share with a teammate are reported" { @@ -62,15 +61,16 @@ commit_target() { @test "a journal carrying a secret is reported" { "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null commit_target - printf '**Status:** ACTIVE\n\n## Live plan pointer\nnone\n\n## Next step for a fresh agent\nx\napi_key = sk-live-4eC39HqLyjWDarjtT1z\n' \ + printf '**Status:** ACTIVE\n\n## Live plan pointer\nnone\n\n## Next step for a fresh agent\nx\napi_key = %s\n' "sk-live-""4eC39HqLyjWDarjtT1z" \ > "$TARGET/sessions/2026-01-02-0304-leak.md" run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" [ "$status" -eq 1 ] - [[ "$output" == *"free of secret-looking content"* ]] + [[ "$output" == *"credential-shaped content"* ]] + [[ "$output" == *"2026-01-02-0304-leak.md"* ]] [[ "$output" != *"sk-live"* ]] } -@test "the doctor reports which journal the pointer would deliver" { +@test "the doctor reports the open candidate journals" { "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null commit_target printf '**Status:** ACTIVE\n\n## Live plan pointer\nnone\n\n## Next step for a fresh agent\nx\n' \ @@ -104,5 +104,27 @@ commit_target() { commit_target run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" [ "$status" -eq 1 ] - [[ "$output" == *"a Stop hook is registered"* ]] + [[ "$output" == *"wires all six journal hooks"* ]] + [[ "$output" == *"missing: Stop"* ]] +} + +@test "leftover v1 hooks are reported" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + jq '.hooks.Stop += [{hooks:[{type:"command",command:"./.claude/hooks/session-journal-nudge.sh"}]}]' \ + "$TARGET/.claude/settings.json" > "$TARGET/settings.new" + mv "$TARGET/settings.new" "$TARGET/.claude/settings.json" + commit_target + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 1 ] + [[ "$output" == *"carries no v1 hooks"* ]] +} + +@test "a hook command without its safety net is reported" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + jq '.hooks.Stop[0].hooks[0].command |= rtrimstr(" || true")' "$TARGET/.claude/settings.json" > "$TARGET/settings.new" + mv "$TARGET/settings.new" "$TARGET/.claude/settings.json" + commit_target + run "$BATS_TEST_DIRNAME/../doctor.sh" "$TARGET" + [ "$status" -eq 1 ] + [[ "$output" == *"ends in '|| true'"* ]] } diff --git a/tests/install.bats b/tests/install.bats index 7d71445..53a0b3c 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -13,9 +13,12 @@ teardown() { teardown_workspace; } @test "fresh install creates Claude wiring, docs, and executable shared hooks" { run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" [ "$status" -eq 0 ] - [ -x "$TARGET/.claude/hooks/session-journal-pointer.sh" ] - [ -x "$TARGET/.claude/hooks/session-journal-nudge.sh" ] - jq -e '.hooks.SessionStart | length == 1' "$TARGET/.claude/settings.json" >/dev/null + [ -x "$TARGET/.claude/hooks/session-journal.py" ] + for event in SessionStart UserPromptSubmit PreToolUse PostToolUse Stop SessionEnd; do + jq -e --arg e "$event" '.hooks[$e] | length == 1' "$TARGET/.claude/settings.json" >/dev/null + jq -e --arg e "$event" '.hooks[$e][0].hooks[0].command | endswith("|| true")' "$TARGET/.claude/settings.json" >/dev/null + done + [ -f "$TARGET/.claude/skills/session-journal/SKILL.md" ] [ -f "$TARGET/sessions/README.md" ] [ -f "$TARGET/CLAUDE.md" ] grep -Fq "Timestamp label: \`$(date +%Z)\`" "$TARGET/sessions/README.md" @@ -43,14 +46,16 @@ teardown() { teardown_workspace; } jq -e '.hooks.SessionStart | length == 1' "$TARGET/.codex/hooks.json" >/dev/null [[ "$(jq -r '.hooks.SessionStart[0].hooks[0].command' "$TARGET/.codex/hooks.json")" == *'git rev-parse --show-toplevel'* ]] [ -f "$TARGET/AGENTS.md" ] + [ -f "$TARGET/.agents/skills/session-journal/agents/openai.yaml" ] + jq -e '.hooks.PreToolUse[0].matcher == "Bash|apply_patch"' "$TARGET/.codex/hooks.json" >/dev/null + jq -e '.hooks.SessionEnd[0].hooks[0].timeout <= 3' "$TARGET/.codex/hooks.json" >/dev/null } @test "custom sessions directory is wired through hook commands" { "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --sessions-dir docs/sessions --codex >/dev/null [ -f "$TARGET/docs/sessions/README.md" ] command="$(jq -r '.hooks.Stop[0].hooks[0].command' "$TARGET/.claude/settings.json")" - [[ "$command" == SESSION_JOURNAL_DIR=* ]] - [[ "$command" == *'$(git rev-parse --show-toplevel 2>/dev/null || pwd)/docs/sessions'* ]] + [[ "$command" == "SESSION_JOURNAL_DIR=docs/sessions python3 "* ]] [[ "$command" != *"$TARGET"* ]] } @@ -91,9 +96,11 @@ teardown() { teardown_workspace; } "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --codex >/dev/null printf '# history\n' > "$TARGET/sessions/2026-01-02-0304-history.md" "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --codex --uninstall >/dev/null - [ ! -e "$TARGET/.claude/hooks/session-journal-pointer.sh" ] - [ ! -e "$TARGET/.claude/hooks/session-journal-nudge.sh" ] + [ ! -e "$TARGET/.claude/hooks/session-journal.py" ] + [ ! -e "$TARGET/.claude/skills/session-journal" ] + [ ! -e "$TARGET/.agents/skills/session-journal" ] [ "$(jq '.hooks.Stop | length' "$TARGET/.claude/settings.json")" -eq 0 ] + [ "$(jq '.hooks.SessionEnd | length' "$TARGET/.claude/settings.json")" -eq 0 ] [ "$(jq '.hooks.Stop | length' "$TARGET/.codex/hooks.json")" -eq 0 ] ! grep -q '' "$TARGET/CLAUDE.md" [ -f "$TARGET/sessions/2026-01-02-0304-history.md" ] @@ -116,12 +123,10 @@ teardown() { teardown_workspace; } grep -Fq 'TEMPLATE.md' "$TARGET/sessions/README.md" } -@test "the installed hooks carry a resolved version stamp" { +@test "the installed hook reports the release version" { "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null version="$(cat "$BATS_TEST_DIRNAME/../VERSION")" - grep -Fq "session-journal $version" "$TARGET/.claude/hooks/session-journal-pointer.sh" - grep -Fq "session-journal $version" "$TARGET/.claude/hooks/session-journal-nudge.sh" - ! grep -Fq '' "$TARGET/.claude/hooks/session-journal-nudge.sh" + [ "$(python3 "$TARGET/.claude/hooks/session-journal.py" version)" = "$version" ] } @test "rerunning preserves the adapted sessions README" { @@ -145,12 +150,12 @@ teardown() { teardown_workspace; } @test "rerunning preserves environment overrides on the generated commands" { "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null - jq '.hooks.Stop[0].hooks[0].command = ("JOURNAL_FRESH_SECS=900 " + .hooks.Stop[0].hooks[0].command)' \ + jq '.hooks.Stop[0].hooks[0].command = ("JOURNAL_NUDGE_MIN=30 " + .hooks.Stop[0].hooks[0].command)' \ "$TARGET/.claude/settings.json" > "$TARGET/settings.new" mv "$TARGET/settings.new" "$TARGET/.claude/settings.json" run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" [ "$status" -eq 0 ] - jq -e '.hooks.Stop[0].hooks[0].command | startswith("JOURNAL_FRESH_SECS=900 ")' "$TARGET/.claude/settings.json" >/dev/null + jq -e '.hooks.Stop[0].hooks[0].command | startswith("JOURNAL_NUDGE_MIN=30 ")' "$TARGET/.claude/settings.json" >/dev/null [ "$(jq '.hooks.Stop | length' "$TARGET/.claude/settings.json")" -eq 1 ] } @@ -175,7 +180,7 @@ teardown() { teardown_workspace; } run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" [ "$status" -eq 65 ] [ ! -e "$TARGET/.claude/settings.json" ] - [ ! -e "$TARGET/.claude/hooks/session-journal-nudge.sh" ] + [ ! -e "$TARGET/.claude/hooks/session-journal.py" ] [ ! -e "$TARGET/sessions/README.md" ] [ -z "$(find "$TARGET" -name '*.session-journal.tmp')" ] [ "$(cksum < "$TARGET/CLAUDE.md")" = "$before" ] @@ -202,7 +207,7 @@ teardown() { teardown_workspace; } printf '\n\n' >> "$TARGET/CLAUDE.md" run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --uninstall [ "$status" -eq 65 ] - [ -x "$TARGET/.claude/hooks/session-journal-nudge.sh" ] + [ -x "$TARGET/.claude/hooks/session-journal.py" ] [ "$(jq '.hooks.Stop | length' "$TARGET/.claude/settings.json")" -eq 1 ] } @@ -239,12 +244,12 @@ teardown() { teardown_workspace; } @test "a quoted environment override survives a rerun" { "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null - jq '.hooks.Stop[0].hooks[0].command = ("JOURNAL_FRESH_SECS=\"900\" " + .hooks.Stop[0].hooks[0].command)' \ + jq '.hooks.Stop[0].hooks[0].command = ("JOURNAL_NUDGE_MIN=\"30\" " + .hooks.Stop[0].hooks[0].command)' \ "$TARGET/.claude/settings.json" > "$TARGET/settings.new" mv "$TARGET/settings.new" "$TARGET/.claude/settings.json" run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" [ "$status" -eq 0 ] - jq -e '.hooks.Stop[0].hooks[0].command | startswith("JOURNAL_FRESH_SECS=\"900\" ")' "$TARGET/.claude/settings.json" >/dev/null + jq -e '.hooks.Stop[0].hooks[0].command | startswith("JOURNAL_NUDGE_MIN=\"30\" ")' "$TARGET/.claude/settings.json" >/dev/null } @test "a command registered under another event cannot contribute to the generated one" { @@ -271,7 +276,7 @@ teardown() { teardown_workspace; } run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --uninstall [ "$status" -eq 65 ] [ "$(cksum < "$TARGET/AGENTS.md")" = "$before" ] - [ -x "$TARGET/.claude/hooks/session-journal-nudge.sh" ] + [ -x "$TARGET/.claude/hooks/session-journal.py" ] } @test "uninstall does not leave behind instruction files it created" { @@ -293,3 +298,51 @@ teardown() { teardown_workspace; } grep -Fq 'Be careful.' "$TARGET/CLAUDE.md" ! grep -Fq 'session-journal:begin' "$TARGET/CLAUDE.md" } + +@test "an install migrates a v1 installation in place" { + mkdir -p "$TARGET/.claude/hooks" "$TARGET/docs/sessions" + printf '#!/bin/bash\n' > "$TARGET/.claude/hooks/session-journal-pointer.sh" + printf '#!/bin/bash\n' > "$TARGET/.claude/hooks/session-journal-nudge.sh" + printf 'journal\n' > "$TARGET/docs/sessions/2026-01-02-0304-old.md" + cat > "$TARGET/.claude/settings.json" <<'JSON' +{"hooks":{"SessionStart":[{"matcher":"*","hooks":[{"type":"command","command":"SESSION_JOURNAL_DIR=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/docs/sessions\" \"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.claude/hooks/session-journal-pointer.sh\""}]}], + "Stop":[{"hooks":[{"type":"command","command":"./mine.sh"},{"type":"command","command":"JOURNAL_FRESH_SECS=900 SESSION_JOURNAL_DIR=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/docs/sessions\" \"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.claude/hooks/session-journal-nudge.sh\""}]}]}} +JSON + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 0 ] + [[ "$output" == *"Reusing the journal directory already configured here: docs/sessions/"* ]] + [ ! -e "$TARGET/.claude/hooks/session-journal-pointer.sh" ] + [ ! -e "$TARGET/.claude/hooks/session-journal-nudge.sh" ] + ! grep -Eq 'session-journal-(pointer|nudge)' "$TARGET/.claude/settings.json" + ! grep -q 'JOURNAL_FRESH_SECS' "$TARGET/.claude/settings.json" + jq -e '.hooks.Stop[] | select(.hooks[].command == "./mine.sh")' "$TARGET/.claude/settings.json" >/dev/null + jq -e '.hooks.Stop[-1].hooks[0].command | startswith("SESSION_JOURNAL_DIR=docs/sessions python3")' "$TARGET/.claude/settings.json" >/dev/null + [ -f "$TARGET/docs/sessions/2026-01-02-0304-old.md" ] + [ ! -d "$TARGET/sessions" ] +} + +@test "--no-skill installs hooks without the agent skill" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" --codex --no-skill >/dev/null + [ ! -e "$TARGET/.claude/skills/session-journal" ] + [ ! -e "$TARGET/.agents/skills/session-journal" ] + [ -x "$TARGET/.claude/hooks/session-journal.py" ] +} + +@test "the installed hooks run end to end in the target" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + command="$(jq -r '.hooks.SessionStart[0].hooks[0].command' "$TARGET/.claude/settings.json")" + out="$(cd "$TARGET" && printf '{"session_id":"s1","source":"startup"}' | CLAUDE_PROJECT_DIR="$TARGET" sh -c "$command")" + printf '%s' "$out" | jq -e '.hookSpecificOutput.additionalContext | contains("TEMPLATE.md")' >/dev/null + stop="$(jq -r '.hooks.Stop[0].hooks[0].command' "$TARGET/.claude/settings.json")" + out="$(cd "$TARGET" && printf '{"session_id":"s1","stop_hook_active":false}' | CLAUDE_PROJECT_DIR="$TARGET" sh -c "$stop")" + [ "$out" = "{}" ] + [ "$(cat "$TARGET/.claude/state/session-journal/.gitignore")" = "*" ] +} + +@test "a missing script never blocks the agent" { + "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" >/dev/null + rm -f "$TARGET/.claude/hooks/session-journal.py" + stop="$(jq -r '.hooks.Stop[0].hooks[0].command' "$TARGET/.claude/settings.json")" + run sh -c "cd '$TARGET' && printf '{}' | $stop" + [ "$status" -eq 0 ] +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash index e4932ce..e81250a 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -1,4 +1,8 @@ setup_workspace() { + # The suite may itself run inside an agent session; its role, session id and + # project directory must not leak into the hooks under test. + unset JOURNAL_ROLE JOURNAL_WORKER_PROMPT JOURNAL_LANDING_BASH JOURNAL_SECRETS SESSION_JOURNAL_SKIP \ + SESSION_JOURNAL_DISABLE SESSION_JOURNAL_STATE_DIR CLAUDE_CODE_SESSION_ID CLAUDE_PID CODEX_THREAD_ID export TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/session-journal-test.XXXXXX")" export CLAUDE_PROJECT_DIR="$TEST_ROOT" export SESSION_JOURNAL_DIR="$TEST_ROOT/sessions" From 600392b13a5bad96bf672f6ce68b7fdd02e13df5 Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Wed, 21 Oct 2026 22:49:34 +0530 Subject: [PATCH 09/14] feat: add the session-journal agent skill and its Codex mirror The skill holds what the scripts cannot decide: where journals live, adapting roles and landing rules to a project's agents, picking or creating a journal, what never goes in one, migrating old journals, and recall. The Codex mirror carries agents/openai.yaml so Codex discovers it; a docs test keeps the two SKILL.md files identical. --- codex/skills/session-journal/SKILL.md | 130 ++++++++++++++++++ .../skills/session-journal/agents/openai.yaml | 6 + skills/session-journal/SKILL.md | 130 ++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 codex/skills/session-journal/SKILL.md create mode 100644 codex/skills/session-journal/agents/openai.yaml create mode 100644 skills/session-journal/SKILL.md diff --git a/codex/skills/session-journal/SKILL.md b/codex/skills/session-journal/SKILL.md new file mode 100644 index 0000000..96fa786 --- /dev/null +++ b/codex/skills/session-journal/SKILL.md @@ -0,0 +1,130 @@ +--- +name: session-journal +description: Adopt, adapt, and operate session-bound journals for coding agents. Use when installing session-journal into a project, when adapting it to a project's layout, agents, or multi-agent setup, when migrating or backfilling old journals, when choosing which journal to read, continue, or create, when a session-journal hook message (candidates, IN USE, SESSION-JOURNAL nudge, worker) needs acting on, or when recalling a topic from earlier sessions ("when did we discuss X", "find the session where"). +--- + +# Session journal + +A journal is one markdown file per piece of work, written for the next agent. Hooks bind each journal to the +session that writes it. Scripts handle what can be checked mechanically: binding, IN USE locks, candidates, nudges, +the secrets check, and the doctor. This skill covers the judgment calls they cannot make. + +The upstream source is `https://github.com/dctmfoo/session-journal`. In it, `ADOPT.md` is the full adoption +contract and `spec/session-journal-spec.md` is the journal format. + +## 1. Adopt it in a project + +1. Survey before you write anything: + - Runtimes: `.claude/settings.json`, `.codex/hooks.json` or `.codex/config.toml`, and instruction-only agents. + - Instruction files: `CLAUDE.md` and `AGENTS.md`. + - Documentation: plans, specs, ADRs, trackers. + - Ignore rules: is `.claude/` ignored? Is the journal directory ignored? + - Repository shape: a single repo, a monorepo, or a repo where people use worktrees. +2. Pick the journal directory. Use root `sessions/` by default. Use `docs/sessions/` when the project keeps + process docs under `docs/`. A monorepo gets one directory at its root: a journal follows the session, not the + package. Do not put journals in another repository or in a linked worktree. +3. Run the installer from a clone of the upstream repository. Do not hand-merge hooks: + `./install.sh [--sessions-dir docs/sessions] [--plan-pointer ] [--codex] [--no-claude]`. + It refuses rather than clobbering existing configuration. It is safe to run twice. It replaces v1 shell hooks. +4. Adapt what the installer generated: + - `/README.md`: the default plan pointer, the `date +%Z` zone label, and the project's evidence + vocabulary (test names, ticket ids, CI URLs). + - `TEMPLATE.md`: add header lines only if the project needs them (one `** branch:**` line per repository + the work touches, for example). + - The sentinel blocks in `CLAUDE.md` and `AGENTS.md`: keep both in sync when both exist. +5. Verify with `./doctor.sh `. Report what it printed; never claim a hook fired unless you observed it. +6. Commit `.claude/` (and `.codex/`, `.agents/`), the instruction blocks, and the journal directory together. + Until then only your machine has the discipline. `.claude/state/` stays local: it ignores itself. + +## 2. Adapt it to how the project works + +Configure behaviour with environment prefixes on the generated hook commands. A re-run of the installer keeps them. + +| Situation | Setting | +| --- | --- | +| Journals somewhere other than `sessions/` | `--sessions-dir` (written as `SESSION_JOURNAL_DIR`) | +| An orchestrator sends workers a recognisable first prompt | `JOURNAL_WORKER_PROMPT=''`: those sessions are soft workers and are never nudged | +| Dispatching or deploying from the shell counts as landing work | `JOURNAL_LANDING_BASH=''` (git commit and push already count) | +| A private repository where the owner wants credentials recorded | `JOURNAL_SECRETS=warn`, only with the owner's explicit decision | +| Sessions should not mark a journal PAUSED when they end | `JOURNAL_PAUSE_ON_END=0` | +| Different lock or nudge timing | `JOURNAL_LIVE_MIN`, `JOURNAL_NUDGE_MIN`, `JOURNAL_MAX_KB` | + +Anything the hooks cannot know, such as the evidence vocabulary or which documents govern which work, goes in +`/README.md`. + +## 3. Operate it every session + +1. **At start**, read what the SessionStart hook printed: + - **Bound** (after resume or compact): re-read that journal and continue it. + - **Candidates**: after the user's first message, decide whether the task really continues one of them. + - If it does, read the journal and append to it. + - If the journal you need is marked IN USE, create your own journal with + `**Continues:** /.md`. + - Otherwise create a new journal from `TEMPLATE.md`. The filename uses the session-start time in local + time: `YYYY-MM-DD-HHMM-.md`. + - **Never** pick a journal by newest name or mtime (`ls | tail`). For related work, use + `python3 .claude/hooks/session-journal.py candidates ` or `find `. +2. **Journal only when something lands**: a commit, an edit that matters, a dispatch, a verified result, or a + decision. A turn that only read or answered changes nothing in the journal. When the Stop hook asks for an + update on a read-only turn, ignore it. One update per landed turn is enough. +3. **THIN or DETAILED.** When a plan, spec, or tracker governs the work, put its path under + `## Live plan pointer` (THIN). Milestones are then one line each and point into that document; do not retell + it. Use `none` (DETAILED) only when no such document exists; the journal is then the record. +4. **Keep `## Next step for a fresh agent` standalone.** It is shown verbatim to the next session. Name the + command, file, or decision. +5. **Close it.** When the work is finished, set `**Status:** DONE`. When it is unfinished, set + `**Status:** PAUSED`. Before `/clear`, bring `Where we are now` and `Next step` up to date. +6. **Split, don't bloat.** One live writer per journal. When a journal passes about 40 KB, or starts covering a + second workstream, start a new journal that says `**Continues:**`. + +## 4. Lead and worker roles + +- A **lead** (the default) owns a journal. It records what it dispatched, and later the outcome, one line each. +- A **worker** never writes journals. Its report to the lead is the record. + - Start workers with `JOURNAL_ROLE=worker`. That makes them hard workers: their journal writes are denied. + - A worker whose task explicitly asks for a deliverable journal is started without that variable, or runs + `python3 .claude/hooks/session-journal.py role lead`. +- When a worker's turn lands work, the hooks add a line to a local ledger. The lead sees that line on its next + prompt. Record the outcome in the lead's journal then. +- Linked worktrees still write journals in the main checkout's directory. Give a new journal a + `**Worktree:** ()` header line; the SessionStart hook prints the exact line to use. + +## 5. Never in a journal + +- Credentials or fragments of them: passwords, keys, tokens, connection strings, private keys. Write where the + secret lives instead: a vault entry name, an env var such as `$API_TOKEN`, or ``. The PreToolUse hook + denies credential-shaped writes. Mark a line with `` only after a human has checked + that it holds no credential. +- Customer data, personal data, internal hostnames that must not leave the project, and large logs. +- Verbatim transcripts, duplicated plan text, and unverified claims stated as fact. + +## 6. Migrate or backfill old journals + +1. Run `./install.sh ` once. It replaces the v1 pointer and nudge hooks and keeps every journal, README, + and template. +2. Do not rename existing journals. Names without HHMM still work. +3. Optional, with the owner's agreement: + - `python3 .claude/hooks/session-journal.py sweep 168 --by-git --dry-run` lists stale `ACTIVE` journals. + Rerun it without `--dry-run` to mark them `PAUSED`. + - A journal that lacks `## Next step for a fresh agent` or a `**Status:**` line gets those added by hand, + from its own content. Do not invent history. +4. Journals kept in another place, such as `docs/notes/`: move them with `git mv` into the journal directory under + the dated name, and say so in a commit message. Do not copy them. +5. Run `./doctor.sh ` and `python3 .claude/hooks/session-journal.py scan`, then commit. + +## 7. Recall a topic from earlier sessions + +Search in this order, and do not report "never discussed" until all three tiers have run: + +1. **Journals**: `python3 .claude/hooks/session-journal.py find [word...]`. Try 3 to 6 variants of the + user's words: synonyms, component names, ticket ids. +2. **Governing documents**: grep the plans and specs that THIN journals point to. Map a document hit back to + journals with `grep -l '' /*.md`. +3. **Transcripts**: only when tiers 1 and 2 come up thin. Use `reconstruct ` for the sessions that + wrote a journal, or grep the agent's transcript store. Label those hits "discussion only, nothing landed". + +Answer in three parts: + +- 2 to 5 sentences of thread history. +- A table of hits (date, journal, state, governing document). +- The newest relevant `Next step for a fresh agent`, as the point to continue from. diff --git a/codex/skills/session-journal/agents/openai.yaml b/codex/skills/session-journal/agents/openai.yaml new file mode 100644 index 0000000..2c1192f --- /dev/null +++ b/codex/skills/session-journal/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Session Journal" + short_description: "Adopt, adapt, and operate session-bound journals: pick or create this session's journal, lead and worker roles, migration, and topic recall." + default_prompt: "Use $session-journal to adopt session journals in this project, to decide which journal this session continues or creates, to act on a session-journal hook message, or to recall a topic from earlier sessions." +policy: + allow_implicit_invocation: true diff --git a/skills/session-journal/SKILL.md b/skills/session-journal/SKILL.md new file mode 100644 index 0000000..96fa786 --- /dev/null +++ b/skills/session-journal/SKILL.md @@ -0,0 +1,130 @@ +--- +name: session-journal +description: Adopt, adapt, and operate session-bound journals for coding agents. Use when installing session-journal into a project, when adapting it to a project's layout, agents, or multi-agent setup, when migrating or backfilling old journals, when choosing which journal to read, continue, or create, when a session-journal hook message (candidates, IN USE, SESSION-JOURNAL nudge, worker) needs acting on, or when recalling a topic from earlier sessions ("when did we discuss X", "find the session where"). +--- + +# Session journal + +A journal is one markdown file per piece of work, written for the next agent. Hooks bind each journal to the +session that writes it. Scripts handle what can be checked mechanically: binding, IN USE locks, candidates, nudges, +the secrets check, and the doctor. This skill covers the judgment calls they cannot make. + +The upstream source is `https://github.com/dctmfoo/session-journal`. In it, `ADOPT.md` is the full adoption +contract and `spec/session-journal-spec.md` is the journal format. + +## 1. Adopt it in a project + +1. Survey before you write anything: + - Runtimes: `.claude/settings.json`, `.codex/hooks.json` or `.codex/config.toml`, and instruction-only agents. + - Instruction files: `CLAUDE.md` and `AGENTS.md`. + - Documentation: plans, specs, ADRs, trackers. + - Ignore rules: is `.claude/` ignored? Is the journal directory ignored? + - Repository shape: a single repo, a monorepo, or a repo where people use worktrees. +2. Pick the journal directory. Use root `sessions/` by default. Use `docs/sessions/` when the project keeps + process docs under `docs/`. A monorepo gets one directory at its root: a journal follows the session, not the + package. Do not put journals in another repository or in a linked worktree. +3. Run the installer from a clone of the upstream repository. Do not hand-merge hooks: + `./install.sh [--sessions-dir docs/sessions] [--plan-pointer ] [--codex] [--no-claude]`. + It refuses rather than clobbering existing configuration. It is safe to run twice. It replaces v1 shell hooks. +4. Adapt what the installer generated: + - `/README.md`: the default plan pointer, the `date +%Z` zone label, and the project's evidence + vocabulary (test names, ticket ids, CI URLs). + - `TEMPLATE.md`: add header lines only if the project needs them (one `** branch:**` line per repository + the work touches, for example). + - The sentinel blocks in `CLAUDE.md` and `AGENTS.md`: keep both in sync when both exist. +5. Verify with `./doctor.sh `. Report what it printed; never claim a hook fired unless you observed it. +6. Commit `.claude/` (and `.codex/`, `.agents/`), the instruction blocks, and the journal directory together. + Until then only your machine has the discipline. `.claude/state/` stays local: it ignores itself. + +## 2. Adapt it to how the project works + +Configure behaviour with environment prefixes on the generated hook commands. A re-run of the installer keeps them. + +| Situation | Setting | +| --- | --- | +| Journals somewhere other than `sessions/` | `--sessions-dir` (written as `SESSION_JOURNAL_DIR`) | +| An orchestrator sends workers a recognisable first prompt | `JOURNAL_WORKER_PROMPT=''`: those sessions are soft workers and are never nudged | +| Dispatching or deploying from the shell counts as landing work | `JOURNAL_LANDING_BASH=''` (git commit and push already count) | +| A private repository where the owner wants credentials recorded | `JOURNAL_SECRETS=warn`, only with the owner's explicit decision | +| Sessions should not mark a journal PAUSED when they end | `JOURNAL_PAUSE_ON_END=0` | +| Different lock or nudge timing | `JOURNAL_LIVE_MIN`, `JOURNAL_NUDGE_MIN`, `JOURNAL_MAX_KB` | + +Anything the hooks cannot know, such as the evidence vocabulary or which documents govern which work, goes in +`/README.md`. + +## 3. Operate it every session + +1. **At start**, read what the SessionStart hook printed: + - **Bound** (after resume or compact): re-read that journal and continue it. + - **Candidates**: after the user's first message, decide whether the task really continues one of them. + - If it does, read the journal and append to it. + - If the journal you need is marked IN USE, create your own journal with + `**Continues:** /.md`. + - Otherwise create a new journal from `TEMPLATE.md`. The filename uses the session-start time in local + time: `YYYY-MM-DD-HHMM-.md`. + - **Never** pick a journal by newest name or mtime (`ls | tail`). For related work, use + `python3 .claude/hooks/session-journal.py candidates ` or `find `. +2. **Journal only when something lands**: a commit, an edit that matters, a dispatch, a verified result, or a + decision. A turn that only read or answered changes nothing in the journal. When the Stop hook asks for an + update on a read-only turn, ignore it. One update per landed turn is enough. +3. **THIN or DETAILED.** When a plan, spec, or tracker governs the work, put its path under + `## Live plan pointer` (THIN). Milestones are then one line each and point into that document; do not retell + it. Use `none` (DETAILED) only when no such document exists; the journal is then the record. +4. **Keep `## Next step for a fresh agent` standalone.** It is shown verbatim to the next session. Name the + command, file, or decision. +5. **Close it.** When the work is finished, set `**Status:** DONE`. When it is unfinished, set + `**Status:** PAUSED`. Before `/clear`, bring `Where we are now` and `Next step` up to date. +6. **Split, don't bloat.** One live writer per journal. When a journal passes about 40 KB, or starts covering a + second workstream, start a new journal that says `**Continues:**`. + +## 4. Lead and worker roles + +- A **lead** (the default) owns a journal. It records what it dispatched, and later the outcome, one line each. +- A **worker** never writes journals. Its report to the lead is the record. + - Start workers with `JOURNAL_ROLE=worker`. That makes them hard workers: their journal writes are denied. + - A worker whose task explicitly asks for a deliverable journal is started without that variable, or runs + `python3 .claude/hooks/session-journal.py role lead`. +- When a worker's turn lands work, the hooks add a line to a local ledger. The lead sees that line on its next + prompt. Record the outcome in the lead's journal then. +- Linked worktrees still write journals in the main checkout's directory. Give a new journal a + `**Worktree:** ()` header line; the SessionStart hook prints the exact line to use. + +## 5. Never in a journal + +- Credentials or fragments of them: passwords, keys, tokens, connection strings, private keys. Write where the + secret lives instead: a vault entry name, an env var such as `$API_TOKEN`, or ``. The PreToolUse hook + denies credential-shaped writes. Mark a line with `` only after a human has checked + that it holds no credential. +- Customer data, personal data, internal hostnames that must not leave the project, and large logs. +- Verbatim transcripts, duplicated plan text, and unverified claims stated as fact. + +## 6. Migrate or backfill old journals + +1. Run `./install.sh ` once. It replaces the v1 pointer and nudge hooks and keeps every journal, README, + and template. +2. Do not rename existing journals. Names without HHMM still work. +3. Optional, with the owner's agreement: + - `python3 .claude/hooks/session-journal.py sweep 168 --by-git --dry-run` lists stale `ACTIVE` journals. + Rerun it without `--dry-run` to mark them `PAUSED`. + - A journal that lacks `## Next step for a fresh agent` or a `**Status:**` line gets those added by hand, + from its own content. Do not invent history. +4. Journals kept in another place, such as `docs/notes/`: move them with `git mv` into the journal directory under + the dated name, and say so in a commit message. Do not copy them. +5. Run `./doctor.sh ` and `python3 .claude/hooks/session-journal.py scan`, then commit. + +## 7. Recall a topic from earlier sessions + +Search in this order, and do not report "never discussed" until all three tiers have run: + +1. **Journals**: `python3 .claude/hooks/session-journal.py find [word...]`. Try 3 to 6 variants of the + user's words: synonyms, component names, ticket ids. +2. **Governing documents**: grep the plans and specs that THIN journals point to. Map a document hit back to + journals with `grep -l '' /*.md`. +3. **Transcripts**: only when tiers 1 and 2 come up thin. Use `reconstruct ` for the sessions that + wrote a journal, or grep the agent's transcript store. Label those hits "discussion only, nothing landed". + +Answer in three parts: + +- 2 to 5 sentences of thread history. +- A table of hits (date, journal, state, governing document). +- The newest relevant `Next step for a fresh agent`, as the point to continue from. From d2fb60723d76ac329e4764b137318f96d1ff2b14 Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Wed, 21 Oct 2026 22:49:34 +0530 Subject: [PATCH 10/14] docs: describe 2.0.0 and dogfood it in this repository Spec 2.0 replaces newest-journal selection with session binding and states the update, role and status rules. README, ADOPT.md (now routed through the skill), SECURITY.md, CHANGELOG and the verification record follow. This repository's own installation moves to 2.0. --- .claude/hooks/session-journal-nudge.sh | 250 ---- .claude/hooks/session-journal-pointer.sh | 154 --- .claude/hooks/session-journal.py | 1546 ++++++++++++++++++++++ .claude/settings.json | 52 +- ADOPT.md | 145 +- CHANGELOG.md | 78 ++ CLAUDE.md | 4 +- README.md | 206 ++- SECURITY.md | 81 +- docs/verification.md | 23 +- examples/adopted-repo-tour.md | 19 +- sessions/TEMPLATE.md | 4 +- spec/session-journal-spec.md | 135 +- 13 files changed, 2120 insertions(+), 577 deletions(-) delete mode 100755 .claude/hooks/session-journal-nudge.sh delete mode 100755 .claude/hooks/session-journal-pointer.sh create mode 100755 .claude/hooks/session-journal.py diff --git a/.claude/hooks/session-journal-nudge.sh b/.claude/hooks/session-journal-nudge.sh deleted file mode 100755 index f9097f9..0000000 --- a/.claude/hooks/session-journal-nudge.sh +++ /dev/null @@ -1,250 +0,0 @@ -#!/bin/bash -# Stop hook: enforce a fresh session journal and reject secret-looking content. -# -# Claude Code and Codex pass JSON on stdin. stop_hook_active prevents a second -# freshness block, while the secrets guard always runs. Some runners provide no -# stdin; for those only, a short-lived marker supplies one-shot loop protection. -# -# session-journal 1.1.0 — https://github.com/dctmfoo/session-journal (MIT) - -set -e - -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" -SESSIONS_DIR="${SESSION_JOURNAL_DIR:-$PROJECT_DIR/sessions}" -case "$SESSIONS_DIR" in - /*) ;; - *) SESSIONS_DIR="$PROJECT_DIR/$SESSIONS_DIR" ;; -esac - -FRESH_SECS="${JOURNAL_FRESH_SECS:-300}" -case "$FRESH_SECS" in - ''|*[!0-9]*) - echo "SESSION-JOURNAL: JOURNAL_FRESH_SECS='$FRESH_SECS' is not a whole number of seconds; using 300." >&2 - FRESH_SECS=300 - ;; -esac - -# One-shot markers exist to break a stop cascade within a single session. Without -# a TTL the marker outlives the session that armed it and silently swallows the -# next session's first nudge, so anything older than this is treated as stale. -MARKER_TTL="${JOURNAL_STOP_MARKER_TTL_SECS:-60}" -case "$MARKER_TTL" in - ''|*[!0-9]*) MARKER_TTL=60 ;; -esac - -# Read the WHOLE stdin payload, not just its first line. A single `read` stops -# at the first newline, so a runner that pretty-prints its hook JSON would hand -# us "{", which defeats the stop_hook_active check AND suppresses the marker -# fallback below (because the payload is non-empty), leaving no loop protection -# at all. The per-read timeout is kept: some runners attach a stdin they never -# close, and a plain `cat` would hang the Stop hook forever. -INPUT="" -STDIN_LINE="" -while IFS= read -r -t 2 STDIN_LINE; do - INPUT="$INPUT$STDIN_LINE -" - STDIN_LINE="" -done -[ -z "$STDIN_LINE" ] || INPUT="$INPUT$STDIN_LINE" - -STOP_ACTIVE=false -if [ -n "$INPUT" ]; then - if command -v python3 >/dev/null 2>&1; then - STOP_ACTIVE=$(printf '%s' "$INPUT" | python3 -c 'import json,sys -try: print("true" if json.load(sys.stdin).get("stop_hook_active") is True else "false") -except Exception: print("false")' 2>/dev/null || printf false) - elif command -v jq >/dev/null 2>&1; then - STOP_ACTIVE=$(printf '%s' "$INPUT" | jq -r 'if .stop_hook_active == true then "true" else "false" end' 2>/dev/null || printf false) - elif printf '%s' "$INPUT" | tr '\n' ' ' | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then - STOP_ACTIVE=true - fi -fi - -MARKER="" -if [ -z "$INPUT" ]; then - PROJECT_KEY=$(printf '%s' "$PROJECT_DIR" | cksum | awk '{print $1}') - MARKER="${TMPDIR:-/tmp}/session-journal-nudge-$PROJECT_KEY.d" - if [ -d "$MARKER" ]; then - MARKER_MTIME="" - MARKER_MTIME=$(stat -f %m "$MARKER" 2>/dev/null || stat -c %Y "$MARKER" 2>/dev/null || printf '') - rmdir "$MARKER" 2>/dev/null || true - if [ -n "$MARKER_MTIME" ] && [ "$(( $(date +%s) - MARKER_MTIME ))" -le "$MARKER_TTL" ]; then - STOP_ACTIVE=true - fi - fi -fi - -# A Stop hook is not an installer: it must not create the sessions directory as -# a side effect. The glob below handles a nonexistent directory correctly. - -# Newest journal: mtime leads, filename settles ties. mtime alone is wrong -# whenever several journals share a timestamp -- a fresh `git clone`, a branch -# checkout, an unpacked archive -- because bash 3.2 compares `-nt` at -# whole-second resolution and the tie keeps the alphabetically OLDEST file. -LATEST="" -LATEST_BASE="" -for candidate in "$SESSIONS_DIR"/*.md; do - [ -e "$candidate" ] || continue - base=$(basename "$candidate") - case "$base" in README.md|TEMPLATE.md) continue ;; esac - if [ -z "$LATEST" ]; then - LATEST=$candidate - LATEST_BASE=$base - elif [ "$candidate" -nt "$LATEST" ]; then - LATEST=$candidate - LATEST_BASE=$base - elif [ ! "$LATEST" -nt "$candidate" ] && [ "$base" \> "$LATEST_BASE" ]; then - LATEST=$candidate - LATEST_BASE=$base - fi -done - -# The allowlist applies only to the line carrying the marker. Values are never -# echoed back: diagnostics identify line numbers so a hook cannot leak a secret. -if [ -n "$LATEST" ]; then - CANDIDATES=$(grep -nEv '' "$LATEST" 2>/dev/null || true) - - # Credential shapes that carry their own prefix are unambiguous: a JWT, a - # bearer value, an AWS key id, a GitHub or Slack token, a private-key header. - NAMED=$(printf '%s\n' "$CANDIDATES" \ - | grep -Ei 'e[y]J[A-Za-z0-9_-]{20,}|Bearer[[:space:]]+[A-Za-z0-9._-]{16,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{16,}|-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----' || true) - - # `key: value` assignments need the VALUE inspected, not just the key name. - # The old pattern flagged any 6+ non-space token after a credential key, which - # blocked `password: ` and `token: stored-in-ci-secrets` -- the exact - # remediations this hook's own error message prescribes, producing a block -> - # redact -> block loop the runtime cannot break. A value is now reported only - # when it is long enough to be a credential, drawn from a credential alphabet, - # and not a placeholder -- either a leading symbol (, $ENV_VAR) or a - # leading word that describes where the secret lives (stored-in-ci-secrets). - ASSIGNED=$(awk ' - function is_placeholder(v, head) { - if (v ~ /^[<${*("'"'"'\[]/) return 1 - head = v - sub(/[-_.:].*$/, "", head) - return (head ~ /^(redacted|redact|redacts|placeholder|example|sample|dummy|fake|test|none|null|nil|unset|empty|blank|omitted|elided|scrubbed|masked|hidden|removed|rotated|expired|revoked|sanitized|sanitised|todo|tbd|changeme|see|stored|store|kept|held|managed|provided|supplied|injected|configured|set|use|using|from|in|via|your|my|the|not|no|never|only|env|vault|keychain|n)$/) - } - { - if ($0 ~ //) next - line = $0 - if (match(tolower(line), /(client[_-]?secret|api[_-]?key|secret[_-]?key|access[_-]?key|password|passphrase|token)[[:space:]]*[:=][[:space:]]*/) == 0) next - value = substr(line, RSTART + RLENGTH) - sub(/[[:space:]].*$/, "", value) - sub(/[",;.)]+$/, "", value) - if (length(value) < 12) next - if (value !~ /^[A-Za-z0-9._~+\/=-]+$/) next - if (is_placeholder(tolower(value))) next - print NR ":" line - } - ' "$LATEST" 2>/dev/null || true) - - # A bare long hex string is only suspicious away from digest context. SHA-256 - # artifact receipts, git revisions, and fingerprints are this program's - # evidence, not secrets, and must survive verbatim to stay verifiable. - # - # Context is read over a three-line window, NOT the hash's own line. Prose - # wraps, and a 64-character digest routinely lands on a line of its own with - # "hashes to" or "SHA-256" ending the line above; a single-line check sees a - # bare hex string and cries wolf. - # - # Every non-alphanumeric run collapses to a single space before matching, so - # the keywords compare as whole words. Substring matching let "Avoid", - # "Android" and "paranoid" satisfy `oid`, and "committed" satisfy `commit`, - # silently disabling hex detection for their neighbouring lines. - BARE_HEX=$(awk ' - { lines[NR] = $0 } - END { - for (i = 1; i <= NR; i++) { - if (lines[i] ~ //) continue - ctx = " " tolower((i > 1 ? lines[i-1] : "") " " lines[i] " " (i < NR ? lines[i+1] : "")) " " - gsub(/[^a-z0-9]+/, " ", ctx) - if (ctx ~ / (sha|sha1|sha256|sha384|sha512|hash|hashes|hashed|digest|digests|checksum|checksums|fingerprint|fingerprints|commit|commits|revision|revisions|blob|blobs|oid|oids|etag|etags) /) continue - print i ":" lines[i] - } - }' "$LATEST" 2>/dev/null | grep -Ei '[0-9a-f]{40,}' || true) - - LEAK_LINES=$(printf '%s\n%s\n%s\n' "$NAMED" "$ASSIGNED" "$BARE_HEX" \ - | grep -E '^[0-9]+:' | cut -d: -f1 | sort -n -u | head -n 3 | paste -sd, - || true) - if [ -n "$LEAK_LINES" ]; then - echo "SESSION-JOURNAL-SECRETS-GUARD: secret-looking content found in $LATEST at line(s) $LEAK_LINES (first three shown). Replace the value with a key name, an env-var reference, or a placeholder such as . If a human has confirmed the line carries no credential value, append to that line to exempt it." >&2 - exit 2 - fi -fi - -if [ "$STOP_ACTIVE" = "true" ]; then - printf '{}\n' - exit 0 -fi - -# A trivial session -- a typo fix, a dependency bump -- does not need a journal. -# Without a documented escape the only ways out are to burn stop_hook_active or -# to write a journal about nothing, and both teach the agent to route around the -# discipline. The secrets guard above deliberately still runs. -case "${SESSION_JOURNAL_SKIP:-}" in - ''|0|false|no) ;; - *) printf '{}\n'; exit 0 ;; -esac - -block_once() { - [ -z "$MARKER" ] || mkdir "$MARKER" 2>/dev/null || true - echo "$1" >&2 - exit 2 -} - -if [ -z "$LATEST" ]; then - block_once "SESSION-JOURNAL-REMINDER: No session journal exists. Create YYYY-MM-DD-HHMM-.md from the local template before finishing resumable work. If this session needs no journal, set SESSION_JOURNAL_SKIP=1." -fi - -if ! MTIME=$(stat -f %m "$LATEST" 2>/dev/null); then - MTIME=$(stat -c %Y "$LATEST" 2>/dev/null || printf '0') -fi -NOW=$(date +%s) -AGE=$((NOW - MTIME)) - -PLAN_LINE=$(awk ' - tolower($0) ~ /^##[[:space:]]+live plan pointer[[:space:]]*$/ { inside=1; next } - inside && /^## / { exit } - inside && NF { print; exit } -' "$LATEST" 2>/dev/null | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' || true) -PLAN_LOWER=$(printf '%s' "$PLAN_LINE" | tr '[:upper:]' '[:lower:]') - -mode_reminder() { - case "$PLAN_LOWER" in - ""|none|\*) - block_once "SESSION-JOURNAL-REMINDER (DETAILED mode): $1 Update milestones, files, current state, next step, and Last updated; promote recurring work into a durable plan and switch to THIN." - ;; - *) - block_once "SESSION-JOURNAL-REMINDER (THIN mode — pointer: $PLAN_LINE): $1 Add one-line evidence and refresh current state, next step, and Last updated without duplicating the governing document." - ;; - esac -} - -# Freshness by mtime alone measures a property the agent can set directly: a -# bare `touch` satisfies the gate with zero characters changed. When git can -# tell us whether the journal actually moved, require content, not a timestamp. -journal_records_work() { - local journal=$1 dirty journal_dirty - case "${JOURNAL_REQUIRE_CONTENT_CHANGE:-1}" in - 0|false|no) return 0 ;; - esac - command -v git >/dev/null 2>&1 || return 0 - git -C "$PROJECT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 - # Local-only journals are invisible to `git status`; fall back to mtime. - if git -C "$PROJECT_DIR" check-ignore -q "$journal" 2>/dev/null; then return 0; fi - dirty=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null | head -n 200 || true) - # Nothing changed anywhere: there is no work for the journal to record. - [ -n "$dirty" ] || return 0 - journal_dirty=$(git -C "$PROJECT_DIR" status --porcelain -- "$journal" 2>/dev/null || true) - [ -z "$journal_dirty" ] || return 0 - return 1 -} - -if [ "$AGE" -lt "$FRESH_SECS" ]; then - if journal_records_work "$LATEST"; then - printf '{}\n' - exit 0 - fi - mode_reminder "the newest journal's timestamp moved but its content did not, while other files in this repository did change." -fi - -mode_reminder "the newest journal is ${AGE}s old." diff --git a/.claude/hooks/session-journal-pointer.sh b/.claude/hooks/session-journal-pointer.sh deleted file mode 100755 index ee4735d..0000000 --- a/.claude/hooks/session-journal-pointer.sh +++ /dev/null @@ -1,154 +0,0 @@ -#!/bin/bash -# SessionStart hook: inject a compact pointer to the live session journal. -# Compatible with bash 3.2 and with Claude Code and Codex command hooks. -# -# session-journal 1.1.0 — https://github.com/dctmfoo/session-journal (MIT) - -set -e - -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" -SESSIONS_DIR="${SESSION_JOURNAL_DIR:-$PROJECT_DIR/sessions}" -case "$SESSIONS_DIR" in - /*) ;; - *) SESSIONS_DIR="$PROJECT_DIR/$SESSIONS_DIR" ;; -esac - -# A SessionStart hook is a read path. It must not create directories: a -# read-only checkout or a mistargeted PROJECT_DIR would turn a missing-context -# event into a nonzero exit and lose the injection entirely. The glob below -# handles a nonexistent directory correctly on its own. - -journal_status() { - # First word of the mandatory status line, upper-cased. Empty when absent. - sed -n 's/^[[:space:]]*\*\*Status:\*\*[[:space:]]*//p' "$1" 2>/dev/null \ - | head -n 1 \ - | awk '{print toupper($1)}' \ - | tr -cd '[:upper:]' -} - -# "Newest" is decided by modification time, with the filename as the tie-break. -# mtime alone is wrong whenever a filesystem hands several journals the same -# timestamp -- a fresh `git clone`, a branch checkout, an unpacked archive, a -# restored backup -- because bash 3.2 compares `-nt` at whole-second resolution -# and the tie silently keeps whichever name the glob yielded first, i.e. the -# OLDEST journal. The filename is the session-start time by specification and -# sorts lexicographically, so it settles ties correctly. mtime still leads, so a -# journal appended to across days stays live even though its name is older. -LATEST="" -LATEST_BASE="" -for candidate in "$SESSIONS_DIR"/*.md; do - [ -e "$candidate" ] || continue - base=$(basename "$candidate") - case "$base" in README.md|TEMPLATE.md) continue ;; esac - if [ -z "$LATEST" ]; then - LATEST=$candidate - LATEST_BASE=$base - elif [ "$candidate" -nt "$LATEST" ]; then - LATEST=$candidate - LATEST_BASE=$base - elif [ ! "$LATEST" -nt "$candidate" ] && [ "$base" \> "$LATEST_BASE" ]; then - LATEST=$candidate - LATEST_BASE=$base - fi -done - -# A DONE journal is a finished session, not the live frontier. Prefer the newest -# journal still open for work; fall back to reporting the closed one honestly -# rather than replaying its "no work remains" next step to a fresh agent. -LIVE="" -LIVE_BASE="" -if [ -n "$LATEST" ] && [ "$(journal_status "$LATEST")" = "DONE" ]; then - for candidate in "$SESSIONS_DIR"/*.md; do - [ -e "$candidate" ] || continue - base=$(basename "$candidate") - case "$base" in README.md|TEMPLATE.md) continue ;; esac - case "$(journal_status "$candidate")" in - ACTIVE|BLOCKED) ;; - *) continue ;; - esac - if [ -z "$LIVE" ]; then - LIVE=$candidate - LIVE_BASE=$base - elif [ "$candidate" -nt "$LIVE" ]; then - LIVE=$candidate - LIVE_BASE=$base - elif [ ! "$LIVE" -nt "$candidate" ] && [ "$base" \> "$LIVE_BASE" ]; then - LIVE=$candidate - LIVE_BASE=$base - fi - done -fi - -relative_path() { - local resolved="${1#"$PROJECT_DIR"/}" - [ "$resolved" = "$1" ] && resolved="$1" - printf '%s' "$resolved" -} - -if [ -z "$LATEST" ]; then - REL_SESSIONS=$(relative_path "$SESSIONS_DIR") - MESSAGE="No session journal exists in $REL_SESSIONS/. Once this session's intent is clear, create YYYY-MM-DD-HHMM-.md from $REL_SESSIONS/TEMPLATE.md and follow $REL_SESSIONS/README.md." -else - CLOSED="" - if [ "$(journal_status "$LATEST")" = "DONE" ]; then - if [ -n "$LIVE" ]; then - CLOSED=$(relative_path "$LATEST") - LATEST=$LIVE - else - CLOSED="only" - fi - fi - - RELATIVE=$(relative_path "$LATEST") - STATUS_LINE=$(grep -m 1 '^[[:space:]]*\*\*Status:\*\*' "$LATEST" 2>/dev/null || true) - [ -n "$STATUS_LINE" ] || STATUS_LINE="**Status:** Status not recorded" - - # Heading matching is case-insensitive: a journal written from memory rather - # than from the template routinely title-cases the heading, and an exact match - # silently drops the one section this hook exists to deliver. - NEXT_STEP=$(awk ' - tolower($0) ~ /^##[[:space:]]+next step for a fresh agent[[:space:]]*$/ { inside=1; next } - inside && /^## / { exit } - inside { print } - ' "$LATEST" 2>/dev/null | sed '/^[[:space:]]*$/d' || true) - if [ -z "$NEXT_STEP" ]; then - NEXT_STEP="Next step not recorded. This journal has no \"## Next step for a fresh agent\" section; read it in full, then add that section before ending the turn." - fi - - if [ "$CLOSED" = "only" ]; then - MESSAGE="Most recent session journal: $RELATIVE — its status is DONE, so it is a finished session and not the live frontier. Read it for background, treat this session as new work, and create a new journal once the intent is clear. -$STATUS_LINE -Last recorded next step (from the closed journal): -$NEXT_STEP" - elif [ -n "$CLOSED" ]; then - MESSAGE="Live session journal: $RELATIVE. Read it before acting. Continue the same work by appending to it; start unrelated work in a new journal. A more recent journal ($CLOSED) is already DONE and is background only. -$STATUS_LINE -Next step for a fresh agent: -$NEXT_STEP" - else - MESSAGE="Most recent session journal: $RELATIVE. Read it before acting. Continue the same work by appending to it; start unrelated work in a new journal. -$STATUS_LINE -Next step for a fresh agent: -$NEXT_STEP" - fi -fi - -json_string() { - if command -v python3 >/dev/null 2>&1; then - python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' - elif command -v python >/dev/null 2>&1; then - python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' - else - # Minimal documented fallback: preserve valid JSON by escaping the control - # characters a journal can realistically contain, then flattening newlines. - sed 's/\\/\\\\/g; s/"/\\"/g' \ - | tr '\t' ' ' \ - | tr -d '\r' \ - | tr '\n' ' ' \ - | sed 's/^/"/; s/$/"/' - fi -} - -ESCAPED=$(printf '%s' "$MESSAGE" | json_string) -printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "$ESCAPED" -exit 0 diff --git a/.claude/hooks/session-journal.py b/.claude/hooks/session-journal.py new file mode 100755 index 0000000..0f71e97 --- /dev/null +++ b/.claude/hooks/session-journal.py @@ -0,0 +1,1546 @@ +#!/usr/bin/env python3 +"""Session-bound journals for coding agents (Claude Code and Codex). + +session-journal 2.0.0 - https://github.com/dctmfoo/session-journal (MIT) + +One stdlib-only file, two faces: + + hook hook entry point; reads the hook JSON on stdin. Events: SessionStart, UserPromptSubmit, + PreToolUse, PostToolUse, Stop, SessionEnd + [args] agent / operator CLI: candidates, find, reconstruct, list, health, scan, sweep, whoami, bound, + bind, release, role, skip, version + +A journal belongs to the session(s) that write it. Each session keeps a lease (/leases/.json) that +records the journal it is bound to, its worktree, role and liveness. The first journal write binds the lease. Every +question v1 answered with "which journal is newest?" is answered with "which journal is THIS session bound to?": + + - parallel sessions no longer pick up, or get nudged about, each other's journals; + - a journal another live session is bound to is IN USE: writes into it are denied, and the agent is told to start + its own journal with a '**Continues:**' line instead; + - Stop nudges only when this turn landed work (an edit or a commit) and did not touch this session's own journal, + and at most once per turn. + +Paths resolve against the MAIN checkout of the session's project, never a linked worktree, and never the directory +an agent has `cd`-ed into. A project without a journal directory is left alone: the hooks never create it. + +Environment (all optional): + SESSION_JOURNAL_DIR journal directory, relative to the main checkout (default: sessions) + SESSION_JOURNAL_STATE_DIR lease/trace directory (default:
/.claude/state/session-journal, self-gitignored) + SESSION_JOURNAL_SKIP=1 never nudge this session (the secrets check still runs) + SESSION_JOURNAL_DISABLE=1 every hook does nothing + JOURNAL_ROLE lead (default) or worker; a worker is never nudged and its journal writes are denied + JOURNAL_WORKER_PROMPT regex; a first prompt that matches makes the session a soft worker (never nudged) + JOURNAL_LANDING_BASH regex; extra Bash commands that count as landed work (default: git commit / git push) + JOURNAL_SECRETS deny (default), warn, or off + JOURNAL_LIVE_MIN (45) JOURNAL_LIVE_MAX_H (24) JOURNAL_NUDGE_MIN (20) JOURNAL_MAX_KB (40) + JOURNAL_PAUSE_ON_END 1 (default) marks an ACTIVE journal PAUSED when its session ends; 0 leaves it + JOURNAL_TRANSCRIPTS_DIR where `reconstruct` looks for transcripts (default: ~/.claude/projects) +""" +from __future__ import annotations + +import collections +import contextlib +import datetime as dt +import glob +import json +import math +import os +import re +import subprocess +import sys +import time + +try: + import fcntl +except ImportError: # not POSIX: run without the lock + fcntl = None + +VERSION = '2.0.0' +SKIP_OK = '' +NAME = re.compile(r'^(\d{4}-\d\d-\d\d-(?:\d{4}-)?[A-Za-z0-9._-]+\.md)$') # HHMM is optional: older journals lack it +CLOSED = {'DONE', 'COMPLETE', 'COMPLETED', 'DELIVERED', 'CLOSED', 'SUPERSEDED', 'ABANDONED', '?'} # anything else is open +LANDING_TOOLS = {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'apply_patch'} +COMMIT_BASH = re.compile(r'\bgit\s+(?:-C\s+\S+\s+)?(?:commit|push)\b') +LEAD_OVERRIDE = re.compile(r'JOURNAL_ROLE=lead|keep (?:your own|a) (?:session )?journal', re.I) +PATCH_PATH = re.compile(r'^\*\*\* (?:Add|Update|Delete) File: (.+?)\s*$|^\*\*\* Move to: (.+?)\s*$', re.M) +STOPWORDS = set('the a an and or to of in on for with this that is are be it we you me my our at by from as do does did ' + 'not no yes now then so if please can could would should will just also need want lets let make use ' + 'using check look see read write update add fix run new session journal'.split()) + +# ---------------------------------------------------------------- secrets +# Credential shapes. A rule in VALUE_RULES matched a key NAME; its captured value must also look like a literal +# credential (looks_secret) before it counts, so `password: `, `token: $API_TOKEN` and +# `client_secret: stored-in-ci-secrets` -- the redactions this check asks for -- never block their own fix. +SECRET_KEY = (r'[A-Za-z0-9_.-]*(?:password|passwd|passphrase|pwd|secret|secretkey|client_secret|api_?key|access_?key|' + r'account_?key|private_?key|auth_?token|access_?token|refresh_?token|token)[A-Za-z0-9_.-]*') +PLACEHOLDER = r'(?!<|\$|\{|%|\*{3}|REDACTED|redacted|xxx|XXX|\.\.\.|…|None\b|null\b|""|\'\')' +SECRETS = [ + ('azure-storage-key', re.compile(r'AccountKey=[A-Za-z0-9+/=]{20,}|\b[A-Za-z0-9+/]{86}==')), + ('private-key', re.compile(r'-----BEGIN [A-Z ]*PRIVATE KEY-----')), + ('aws-key', re.compile(r'\bAKIA[0-9A-Z]{16}\b')), + ('github-token', re.compile(r'\bgh[pousr]_[A-Za-z0-9]{30,}|\bgithub_pat_[A-Za-z0-9_]{40,}')), + ('api-key', re.compile(r'\b(?:sk|rk)-[A-Za-z0-9_-]{24,}')), + ('slack-token', re.compile(r'\bxox[baprs]-[A-Za-z0-9-]{10,}')), + ('jwt', re.compile(r'\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}')), + ('bearer-token', re.compile(r'\bBearer\s+' + PLACEHOLDER + r'([A-Za-z0-9._~+/-]{20,}=*)')), + ('sas-token', re.compile(r'[?&]sig=[A-Za-z0-9%+/=]{20,}')), + ('url-credential', re.compile(r'\b[a-z][a-z0-9+.-]*://[^\s/:@<>{}$]+:' + PLACEHOLDER + r'([^\s/@<>]{6,})@')), + # The key NAME may embed the secret word (dbPassword:, SERVICE_API_KEY=), not only equal it. + ('secret-assignment', re.compile(r'(?i)(?)\s*[`"\']?' + + PLACEHOLDER + r'([^\s`"\'|,;)\]}]{6,})')), + ('password-prose', re.compile( + r'(?i)\b(?:password|passwd|passphrase|pwd|secret)\b(?:\s+(?:for|of)\s+[\w .@-]{1,40})?\s+(?:is|was|=|:)\s+' + r'(?:now\s+)?[`"\']?(?!<|\$|\{|\*{3}|REDACTED|xxx|in\b|stored\b|kept\b|available\b|the\b|a\b|an\b|set\b|' + r'rotated\b|not\b|still\b|also\b|correct\b|wrong\b)([^\s`"\'|]{6,})')), + ('password-table-cell', re.compile(r'(?i)\|\s*`?[A-Za-z0-9_.-]*(?:password|passwd|pwd|secret)[A-Za-z0-9_.-]*`?\s*\|' + r'\s*`?([^|\n`]{6,}?)`?\s*\|')), + ('cli-secret-flag', re.compile(r'--(?:password|passwd|client-secret|secret|token)(?:=|\s+)[`"\']?' + PLACEHOLDER + + r'([^\s`"\']{6,})')), +] +VALUE_RULES = {'bearer-token', 'secret-assignment', 'password-prose', 'password-table-cell', 'cli-secret-flag', + 'url-credential'} +NOT_SECRET_WORDS = {'mandatory', 'required', 'optional', 'hardcoded', 'default', 'unchanged', 'invalid', 'expired', + 'missing', 'rotated', 'redacted', 'placeholder', 'changeme', 'checking', 'enabled', 'disabled', + 'true', 'false'} + + +def looks_secret(value): + """A literal credential, not an identifier, path, URL, variable reference or English word.""" + v = re.sub(r'^[`"\'(*_]+|[`"\'.,;:)(*_]+$', '', value) + if len(v) < 6 or v.lower() in NOT_SECRET_WORDS or v.endswith(('...', '…')) or re.search(r'\s', v): + return False + if re.match(r'(?i)(https?|s3|wasbs?|abfss?|jdbc|ldaps?|file)[:/]|[~.]?/', v) or \ + ('/' in v and not re.search(r'[A-Za-z0-9+/]{16,}={0,2}$', v)): + return False # URLs, paths, namespace/secret-name references + if re.fullmatch(r'[a-z0-9]+(?:[-_.][a-z0-9]+)+', v) and len(v) < 24: + return False # lower-case identifiers: app-secret, stored-in-ci-secrets + if re.fullmatch(r'[A-Za-z]+', v): + return False # one word or camelCase identifier + if re.fullmatch(r'[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+', v): + return False # ENV_VAR_NAME or a cipher name used as a reference + if re.fullmatch(r'[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)+', v): + return False # dotted property names: spring.datasource.password + has_digit, has_alpha = bool(re.search(r'\d', v)), bool(re.search(r'[A-Za-z]', v)) + return (has_digit and has_alpha) or bool(re.search(r'[!@#$%^&*+=?~]', v)) + + +def scan_secrets(text): + """[(line_number, rule)] for credential-shaped content. Values are never returned, so they are never echoed. + A line carrying is exempt (a human verified it).""" + hits = [] + for n, line in enumerate((text or '').splitlines(), 1): + if SKIP_OK in line: + continue + for name, rx in SECRETS: + found = False + for m in rx.finditer(line): + if name in VALUE_RULES and not looks_secret(m.group(m.lastindex or 0)): + continue + found = True + break + if found: + hits.append((n, name)) + break + return hits + + +def fmt_hits(hits): + return ', '.join(f'line {n} ({rule})' for n, rule in hits[:5]) + (' ...' if len(hits) > 5 else '') + + +# ---------------------------------------------------------------- paths / state + +def git(*args, cwd=None, timeout=5): + try: + r = subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True, timeout=timeout) + return r.stdout.strip() if r.returncode == 0 else '' + except Exception: + return '' + + +def pid_alive(pid): + try: + os.kill(int(pid), 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + except Exception: + return False + + +def env_pid(): + try: + return int(os.environ.get('CLAUDE_PID') or 0) or None + except ValueError: + return None + + +def env_float(name, default): + try: + return float(os.environ.get(name) or default) + except ValueError: + return float(default) + + +def now_local(): + return dt.datetime.now().astimezone() + + +def worktree_label(top, main, branch): + """Name of a linked worktree. Pooled worktrees are often checked out as //, so a basename that + equals the main checkout's says nothing; the branch says what the worktree is for.""" + name = os.path.basename(top.rstrip(os.sep)) + if name == os.path.basename(main.rstrip(os.sep)) and branch and branch != 'HEAD': + return branch + return name + + +class Ctx: + def __init__(self, cwd=None): + self.cwd = cwd or os.getcwd() + # The session root. A hook's cwd follows the agent's `cd`, so it must not decide which project's journals are + # used: Claude Code keeps $CLAUDE_PROJECT_DIR at the session's project root. Codex reports the session cwd. + self.root = os.environ.get('CLAUDE_PROJECT_DIR') or self.cwd + out = git('rev-parse', '--git-common-dir', '--show-toplevel', '--abbrev-ref', 'HEAD', cwd=self.root).splitlines() + common = out[0] if out else '' + self.common = os.path.normpath(os.path.join(self.root, common)) if common else '' + self.top = out[1] if len(out) > 1 else self.root + self.branch = out[2] if len(out) > 2 else '?' + self.main = os.path.dirname(self.common) if os.path.basename(self.common) == '.git' else self.top + self.worktree = 'main' if os.path.realpath(self.top) == os.path.realpath(self.main) else \ + worktree_label(self.top, self.main, self.branch) + jdir = os.environ.get('SESSION_JOURNAL_DIR') or 'sessions' + self.jdir = os.path.normpath(jdir if os.path.isabs(jdir) else os.path.join(self.main, jdir)) + self.jrel = os.path.relpath(self.jdir, self.main) if self.jdir.startswith(self.main + os.sep) else self.jdir + self.tail = os.path.basename(self.jdir) + sdir = os.environ.get('SESSION_JOURNAL_STATE_DIR') or os.path.join('.claude', 'state', 'session-journal') + self.sdir = sdir if os.path.isabs(sdir) else os.path.join(self.main, sdir) + self.enabled = os.path.isdir(self.jdir) # never create the journal directory + if self.enabled: + for d in ('leases', 'pending'): + os.makedirs(os.path.join(self.sdir, d), exist_ok=True) + ignore = os.path.join(self.sdir, '.gitignore') + if not os.path.exists(ignore): # state is per machine: keep it out of every commit + with open(ignore, 'w') as fh: + fh.write('*\n') + self.live_min = env_float('JOURNAL_LIVE_MIN', 45) + self.live_max_h = env_float('JOURNAL_LIVE_MAX_H', 24) + self.nudge_min = env_float('JOURNAL_NUDGE_MIN', 20) + self.max_bytes = int(env_float('JOURNAL_MAX_KB', 40) * 1024) + self.secrets = (os.environ.get('JOURNAL_SECRETS') or 'deny').strip().lower() + t = re.escape(self.tail) + r'/' + d = t + r'\d{4}-\d\d-\d\d-' + self.mention = re.compile(t + r'(\d{4}-\d\d-\d\d-(?:\d{4}-)?[A-Za-z0-9._-]+?\.md)') + self.path_mention = re.compile(r'((?()=]*?/|(?>?\s*["\']?[^\s"\'|;&<>]*' + d), + re.compile(r'\btee\s+(?:-a\s+)?["\']?[^\s"\'|;&<>]*' + d), + re.compile(r'\bsed\s+-i[^|;&]*' + d), + re.compile(r'\b(?:cp|mv|install|rsync)\s[^|;&]*' + d + r'[^\s"\'|;&]*\.md["\']?\s*(?:$|[|;&)])', re.M), + re.compile(r'open\(\s*[^)]*' + d + r'[^)]*,\s*[rb+]*["\'][wax]|Path\(\s*[^)]*' + d + + r'[^)]*\)\s*\.write_(?:text|bytes)|(?:writeFileSync|appendFileSync)\(\s*[^)]*' + d), + ] + extra = os.environ.get('JOURNAL_LANDING_BASH') + try: + self.landing_bash = re.compile(COMMIT_BASH.pattern + ('|' + extra if extra else '')) + except re.error: + self.landing_bash = COMMIT_BASH + here, main_real = os.path.realpath(__file__), os.path.realpath(self.main) + self.script = os.path.relpath(here, main_real) if here.startswith(main_real + os.sep) else here + + # locking ----------------------------------------------------------- + @contextlib.contextmanager + def lock(self, timeout=3.0): + """Serialise lease read-modify-write across hook processes (parallel tool calls, parallel sessions).""" + fd, got = None, False + try: + fd = os.open(os.path.join(self.sdir, '.lock'), os.O_CREAT | os.O_RDWR, 0o644) + t0 = time.time() + while fcntl: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + got = True + break + except OSError: + if time.time() - t0 > timeout: + break + time.sleep(0.01) + except OSError: + pass + try: + yield got + finally: + if fd is not None: + if got and fcntl: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + # leases ------------------------------------------------------------ + def lease_path(self, sid): + return os.path.join(self.sdir, 'leases', re.sub(r'[^A-Za-z0-9_-]', '', sid or 'nosid') + '.json') + + def lease(self, sid): + try: + with open(self.lease_path(sid)) as fh: + return json.load(fh) + except Exception: + return None + + def save_lease(self, lease, touch=True): + if touch: + lease['heartbeat'] = time.time() + p = self.lease_path(lease['sid']) + tmp = f'{p}.{os.getpid()}.tmp' + with open(tmp, 'w') as fh: + json.dump(lease, fh, indent=1) + os.replace(tmp, p) + return lease + + def leases(self): + out = [] + for p in glob.glob(os.path.join(self.sdir, 'leases', '*.json')): + try: + with open(p) as fh: + out.append(json.load(fh)) + except Exception: + pass + return out + + def is_live(self, lease, now=None): + if lease.get('ended'): + return False + age = (now or time.time()) - (lease.get('heartbeat') or 0) + if age < self.live_min * 60: + return True + # An idle chat or a long autonomous turn keeps its journal while its agent process lives (capped). + return bool(lease.get('pid')) and age < self.live_max_h * 3600 and pid_alive(lease['pid']) + + def owners(self, journal, me=None): + """Live sessions other than `me` bound to journal. If both are bound to it, the earlier binder owns it.""" + mine = me if me and me.get('journal') == journal else None + out = [] + for l in self.leases(): + if l.get('journal') != journal or (me and l.get('sid') == me.get('sid')) or not self.is_live(l): + continue + if mine and (l.get('bound_at') or 0) > (mine.get('bound_at') or 0): + continue + out.append(l) + return out + + def trace(self, **kw): + kw['t'] = now_local().isoformat(timespec='seconds') + p = os.path.join(self.sdir, 'events.jsonl') + try: + if os.path.exists(p) and os.path.getsize(p) > 2 * 1024 * 1024: + os.replace(p, p + '.1') + with open(p, 'a') as f: + f.write(json.dumps(kw) + '\n') + except OSError: + pass + + # journals ---------------------------------------------------------- + def journals(self): + try: + return sorted(os.path.join(self.jdir, n) for n in os.listdir(self.jdir) if NAME.match(n)) + except OSError: + return [] + + def jpath(self, name): + return os.path.join(self.jdir, name) + + def show(self, name): + return f'{self.jrel}/{name}' + + def journal_name(self, path): + """The journal name when path is //.md, else None.""" + if not path: + return None + path = str(path).replace('\\', '/') + head, base = os.path.split(path) + if NAME.match(base) and os.path.basename(head) == self.tail: + return base + return None + + def bash_journal_writes(self, cmd, cwd=None): + """Journal names a shell command certainly writes. With `cwd`, a name whose path visibly points at another + directory (an absolute or relative path that is not this journal directory), or that sits behind a variable + and does not exist here, is someone else's: test fixtures and other repositories often mention a + `sessions/` path inside a heredoc.""" + if not cmd or self.tail + '/' not in cmd: + return [] + if not any(r.search(cmd) for r in self.bash_writes): + return [] + names = set() + for m in self.path_mention.finditer(cmd): + prefix, name = m.group(1), m.group(2) + if cwd is None: # after the fact: a written journal exists + if not os.path.exists(self.jpath(name)): + continue + else: + if prefix.startswith(('$', '~')) or '$(' in prefix or '`' in prefix: + if not os.path.exists(self.jpath(name)): + continue + elif os.path.realpath(os.path.join(cwd, prefix, self.tail)) != os.path.realpath(self.jdir): + continue + names.add(name) + return sorted(names) + + +def header(text, key): + m = re.search(r'^\*\*' + re.escape(key) + r':\*\*\s*(.+)$', text, re.M) + return m.group(1).strip() if m else '' + + +def section(text, name): + m = re.search(r'^##\s+' + re.escape(name) + r'[^\n]*\n(.*?)(?=^## |\Z)', text, re.M | re.S | re.I) + return m.group(1).strip() if m else '' + + +def read(path): + try: + with open(path, errors='replace') as fh: + return fh.read() + except OSError: + return None + + +def card(ctx, name): + text = read(ctx.jpath(name)) + if text is None: + return None + m = re.search(r'^# (?:Session: )?(.+)$', text, re.M) + status = header(text, 'Status') + pointer = section(text, 'Live plan pointer') + return { + 'journal': name, 'title': (m.group(1) if m else name).strip(), + 'status': status.split()[0].upper().rstrip(':;,.') if status else '?', + 'label': re.sub(r'\s+', ' ', status or '?').strip()[:60], 'updated': header(text, 'Last updated'), + 'pointer': pointer.splitlines()[0].strip() if pointer else 'none', + 'next': re.sub(r'\s+', ' ', section(text, 'Next step for a fresh agent'))[:400], + 'sessions': header(text, 'Sessions'), 'worktree': header(text, 'Worktree'), 'bytes': len(text.encode()), + 'mtime': os.path.getmtime(ctx.jpath(name)), + } + + +def is_thin(pointer): + return (pointer or 'none').strip().lower().strip('`<>- ') not in ('none', '') + + +# ---------------------------------------------------------------- transcript (Claude Code JSONL) + +def read_transcript(path): + out = [] + if not path or not os.path.exists(path): + return out + with open(path, errors='replace') as fh: + for line in fh: + try: + d = json.loads(line) + except Exception: + continue + if isinstance(d, dict): + out.append(d) + return out + + +def is_human(d): + if d.get('type') != 'user' or d.get('isMeta') or d.get('isSidechain'): + return False + origin = d.get('origin') or {} + if isinstance(origin, dict) and origin.get('kind'): + return origin.get('kind') == 'human' + c = (d.get('message') or {}).get('content') + if isinstance(c, str): + return not c.startswith('<') and 'Stop hook feedback' not in c + if isinstance(c, list): + return any(isinstance(x, dict) and x.get('type') == 'text' and not x.get('text', '').startswith('<') for x in c) \ + and not any(isinstance(x, dict) and x.get('type') == 'tool_result' for x in c) + return False + + +def iso_epoch(ts): + try: + return dt.datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp() + except Exception: + return 0 + + +def patch_paths(patch, cwd): + out = [] + for m in PATCH_PATH.finditer(patch or ''): + p = (m.group(1) or m.group(2) or '').strip() + if p: + out.append(p if os.path.isabs(p) else os.path.join(cwd or '', p)) + return out + + +def patch_added_text(patch): + return '\n'.join(l[1:] for l in (patch or '').splitlines() if l.startswith('+') and not l.startswith('+++')) + + +def tool_targets(ctx, name, inp, cwd=None): + """(journal names written, other files written, bash command) for one tool call.""" + if name in ('Edit', 'Write', 'MultiEdit', 'NotebookEdit'): + p = inp.get('file_path') or inp.get('notebook_path') or '' + jn = ctx.journal_name(p) + return ([jn] if jn else []), ([] if jn else [p] if p else []), None + if name == 'apply_patch': + js, files = [], [] + for p in patch_paths(str(inp.get('command') or inp.get('patch') or inp.get('input') or ''), cwd): + jn = ctx.journal_name(p) + (js if jn else files).append(jn or p) + return js, files, None + if name == 'Bash': + cmd = str(inp.get('command', '')) + return ctx.bash_journal_writes(cmd, cwd), [], cmd + return [], [], None + + +def turn_facts(ctx, entries, floor=0): + """Facts about the current turn (since the last human message) and the session so far, from a Claude Code + transcript. `floor` (epoch) ignores journal writes before it: a fork's transcript carries the parent's history. + Other transcript formats yield empty facts; the hooks record their own facts for those.""" + start = 0 + failed = set() # tool calls a hook denied (or that failed) stay in the transcript but wrote nothing + for i, d in enumerate(entries): + if is_human(d): + start = i + elif d.get('type') == 'user': + c = (d.get('message') or {}).get('content') + for x in c if isinstance(c, list) else []: + if isinstance(x, dict) and x.get('type') == 'tool_result' and x.get('is_error'): + failed.add(x.get('tool_use_id')) + f = {'landed': False, 'committed': False, 'turn_journal_writes': set(), 'session_journal_writes': [], + 'turn_start_ts': entries[start].get('timestamp') if entries else None, 'first_prompt': None, + 'last_journal_write_ts': None, 'turn_files': [], 'turn_commits': [], 'last_text': ''} + for i, d in enumerate(entries): + if f['first_prompt'] is None and is_human(d): + c = (d.get('message') or {}).get('content') + f['first_prompt'] = c if isinstance(c, str) else ' '.join( + x.get('text', '') for x in c or [] if isinstance(x, dict)) + if d.get('type') != 'assistant' or d.get('isSidechain'): + continue + for c in (d.get('message') or {}).get('content') or []: + if isinstance(c, dict) and c.get('type') == 'text' and i >= start: + f['last_text'] = c.get('text', '') + if not isinstance(c, dict) or c.get('type') != 'tool_use' or c.get('id') in failed: + continue + written, files, cmd = tool_targets(ctx, c.get('name', ''), c.get('input') or {}) + if i >= start: + if files: + f['landed'] = True + f['turn_files'] += files + if cmd and ctx.landing_bash.search(cmd): + f['landed'] = True + if COMMIT_BASH.search(cmd): + f['committed'] = True + f['turn_commits'].append(re.sub(r'\s+', ' ', cmd)[:160]) + if written and floor and iso_epoch(d.get('timestamp') or '') < floor: + continue + for jn in written: + if jn not in f['session_journal_writes']: + f['session_journal_writes'].append(jn) + f['last_journal_write_ts'] = d.get('timestamp') + if i >= start: + f['turn_journal_writes'].add(jn) + return f + + +def journals_changed_since(ctx, t0): + out = [] + try: + for e in os.scandir(ctx.jdir): + if NAME.match(e.name) and e.stat().st_mtime >= t0 - 1.0: + out.append(e.name) + except OSError: + pass + return sorted(out) + + +# ---------------------------------------------------------------- lease helpers + +def new_lease(ctx, inp): + role = (os.environ.get('JOURNAL_ROLE') or '').strip().lower() + role = role if role in ('worker', 'lead') else '' + return {'v': 2, 'sid': inp.get('session_id') or 'nosid', 'journal': None, 'bound_at': None, + 'role': role or 'lead', 'role_source': 'env' if role else 'default', + 'root': ctx.root, 'worktree': ctx.worktree, 'branch': ctx.branch, 'started': time.time(), 'pid': env_pid(), + 'transcript': inp.get('transcript_path'), + 'runtime': 'codex' if 'turn_id' in inp or '/.codex/' in str(inp.get('transcript_path') or '') + else (os.environ.get('CLAUDE_CODE_ENTRYPOINT') or 'claude'), + 'ended': None, 'history': [], 'writes': []} + + +def get_lease(ctx, inp): + sid = inp.get('session_id') or 'nosid' + lease = ctx.lease(sid) or new_lease(ctx, inp) + lease['transcript'] = inp.get('transcript_path') or lease.get('transcript') + lease['pid'] = env_pid() or lease.get('pid') + lease['ended'] = None + if 'turn_id' in inp: + lease['runtime'] = 'codex' + return lease + + +def hard_worker(lease): + return lease.get('role') == 'worker' and lease.get('role_source') in ('env', 'cli') + + +def skipping(lease): + return (os.environ.get('SESSION_JOURNAL_SKIP') or '').lower() not in ('', '0', 'false', 'no') or lease.get('skip') + + +def bind(ctx, lease, name, why): + if name and lease.get('journal') != name: + if lease.get('journal'): + lease.setdefault('history', []).append(lease['journal']) + lease['journal'] = name + lease['bound_at'] = time.time() + ctx.trace(ev='bind', sid=lease['sid'], journal=name, why=why) + + +def turn_key(inp, lease, fallback=None): + """One key per human prompt: Claude Code's prompt_id, Codex's turn_id, else the UserPromptSubmit counter.""" + k = inp.get('prompt_id') or inp.get('turn_id') + if k: + return str(k) + if lease.get('prompt_seq'): + return f"seq-{lease['prompt_seq']}" + return fallback or 'unknown' + + +def turn_record(lease, key): + t = lease.get('turn') or {} + if t.get('key') != key: + t = {'key': key, 'landed': False, 'committed': False, 'journals': [], 'files': [], 'commits': []} + lease['turn'] = t + return t + + +def tokens(s): + return [w for w in re.findall(r'[a-z0-9]+', (s or '').lower().replace('_', ' ')) + if len(w) >= 3 and w not in STOPWORDS] + + +def candidates(ctx, lease, limit=5, first=None): + """Open journals a starting session might continue, ranked: same worktree, journal dirty in git, ACTIVE, and the + last write by an agent session (a background job bumping mtime does not count).""" + dirty = set() + for line in git('status', '--porcelain', '--', ctx.jdir, cwd=ctx.main).splitlines(): + dirty.add(os.path.basename(line[3:].strip().strip('"'))) + session_writes = {} + for l in ctx.leases(): + for w in l.get('writes') or []: + session_writes[w[0]] = max(session_writes.get(w[0], 0), w[1]) + rows = [] + for p in ctx.journals()[-300:]: + c = card(ctx, os.path.basename(p)) + if not c or c['status'] in CLOSED: + continue + owners = ctx.owners(c['journal'], me=lease) + last = session_writes.get(c['journal']) or c['mtime'] + score = -((time.time() - last) / 3600.0) + where = c['worktree'] or c['sessions'] + if where and lease.get('worktree') and lease['worktree'] != 'main' and lease['worktree'] in where: + score += 48 + if c['label'].upper().startswith(('ACTIVE', 'IN PROGRESS')): + score += 6 + if c['journal'] in dirty: + score += 72 + if first and c['journal'] == first: + score += 10 ** 6 + rows.append((score, c, owners)) + rows.sort(key=lambda r: -r[0]) + return rows[:limit] + + +def fmt_card(ctx, c, owners): + if owners: + o = owners[0] + tag = f"IN USE by live session {o['sid'][:8]} ({o.get('worktree', '?')}) - read only, do not append" + else: + tag = c['label'] + (f", {c['bytes'] // 1024} KB - continue it in a new journal with Continues:" + if c['bytes'] > ctx.max_bytes else '') + return f" - {ctx.show(c['journal'])} [{tag}] {c['title'][:70]} | next: {c['next'][:160]}" + + +def fmt_candidates(ctx, rows): + return '\n'.join(fmt_card(ctx, c, owners) for _, c, owners in rows) + + +def related(ctx, lease, prompt, exclude, limit=2, threshold=4.0): + """Open journals whose name or title match the first prompt (IDF-weighted) and were not listed at start-up.""" + q = set(tokens(prompt[:2000])) + if not q: + return [] + docs = {} + for p in ctx.journals(): + n = os.path.basename(p) + try: + with open(p, errors='replace') as fh: + head = fh.read(3000) + except OSError: + continue + m = re.search(r'^# (?:Session: )?(.+)$', head, re.M) + slug = re.sub(r'^\d{4}-\d\d-\d\d-(?:\d{4}-)?|\.md$', '', n).replace('-', ' ') + docs[n] = set(tokens(slug + ' ' + (m.group(1) if m else ''))) + df = collections.Counter(w for s in docs.values() for w in s) + n_docs = max(len(docs), 1) + scored = sorted(((sum(math.log(n_docs / (1 + df[w])) for w in s & q), n) for n, s in docs.items() + if n not in exclude), reverse=True) + out = [] + for sc, n in scored[:limit]: + if sc <= threshold: + break + c = card(ctx, n) + if c and c['status'] not in CLOSED: + out.append((c, ctx.owners(n, me=lease))) + return out + + +def predecessor_of(ctx, lease): + """The session this window ran before /clear: it ended with reason 'clear' moments ago in the same process.""" + best = None + for l in ctx.leases(): + if l.get('sid') == lease['sid'] or l.get('end_reason') != 'clear' or not l.get('ended'): + continue + if time.time() - l['ended'] > 600: + continue + same = (lease.get('pid') and l.get('pid') == lease.get('pid')) or \ + (not lease.get('pid') and l.get('root') == lease.get('root') and time.time() - l['ended'] < 30) + if same and (best is None or l['ended'] > best['ended']): + best = l + return best + + +# ---------------------------------------------------------------- hook handlers + +def on_session_start(ctx, inp): + src = inp.get('source') or 'startup' + msgs = [] + parent_journal = None + if src == 'fork': + hist = turn_facts(ctx, read_transcript(inp.get('transcript_path')))['session_journal_writes'] + parent_journal = hist[-1] if hist else None + with ctx.lock(): + lease = get_lease(ctx, inp) + if src == 'fork' and not lease.get('bind_floor'): + lease['bind_floor'] = time.time() # history before the fork belongs to the parent session + lease['source'] = 'fork' + pred = predecessor_of(ctx, lease) if src == 'clear' and not lease.get('journal') else None + if src == 'clear' and not pred and not lease.get('journal'): + time.sleep(0.3) # the old session's SessionEnd may still be finishing + pred = predecessor_of(ctx, lease) + if pred: + lease['predecessor'] = pred['sid'] + ctx.save_lease(lease) + readme = f'{ctx.jrel}/README.md' + if lease['role'] == 'worker': + msgs.append(f'Journal: this session is a WORKER (JOURNAL_ROLE=worker). Do not create or edit {ctx.jrel}/*.md; ' + 'report results to your lead, whose journal records the dispatch and the outcome.') + elif lease.get('journal'): + c = card(ctx, lease['journal']) or {} + msgs.append(f"Journal: this session ({lease['sid'][:8]}) is bound to {ctx.jpath(lease['journal'])} " + f"(source={src}). Continue it; re-read it now if context was compacted. " + f"Next step recorded there: {c.get('next', '')[:300]}") + else: + msgs.append( + f"Journal: no journal is bound to this session ({lease['sid'][:8]}) yet. Journals live in {ctx.jdir}" + + (f" (the main checkout; you are in worktree '{ctx.worktree}', whose own copy of {ctx.jrel}/ is stale: " + f"give a new journal the header line '**Worktree:** {ctx.worktree} ({ctx.branch})')" + if ctx.worktree != 'main' else '') + + ". After the user's first message: if the task continues one of the candidates below, read it and " + f"append; otherwise create {ctx.jrel}/YYYY-MM-DD-HHMM-.md (local time) from {ctx.jrel}/TEMPLATE.md, " + f"following {readme}, and add '**Continues:** {ctx.jrel}/.md' when it picks up earlier work. Your " + "first journal write binds it to this session. Never append to a journal marked IN USE. A turn that " + "only reads or answers needs no journal.") + first = pred.get('journal') if pred else None + if pred and first: + msgs.append(f"You ran /clear: the previous session in this window ({pred['sid'][:8]}) was bound to " + f"{ctx.show(first)}. If this task continues that work, append to it (it is free now); " + "otherwise start a new journal.") + if parent_journal: + msgs.append(f"This session is a fork. The journal in its history, {ctx.show(parent_journal)}, belongs to " + f"the original session: create your own journal with '**Continues:** " + f"{ctx.show(parent_journal)}'.") + rows = candidates(ctx, lease, first=first) + if rows: + msgs.append('Candidates (ranked: same worktree, dirty in git, active, recent):\n' + fmt_candidates(ctx, rows)) + with ctx.lock(): + cur = ctx.lease(lease['sid']) or lease + cur['shown'] = [c['journal'] for _, c, _ in rows] + ctx.save_lease(cur) + msgs.append(f"Older work: `python3 {ctx.script} find `; open journals on a topic: " + f"`python3 {ctx.script} candidates `; a journal's history: `... reconstruct `.") + ctx.trace(ev='SessionStart', sid=lease['sid'], source=src, bound=lease.get('journal'), role=lease['role'], + predecessor=(pred or {}).get('sid')) + return {'hookSpecificOutput': {'hookEventName': 'SessionStart', 'additionalContext': '\n'.join(msgs)}} + + +def on_user_prompt(ctx, inp): + prompt = inp.get('prompt', '') or '' + texts = [] + worker_rx = os.environ.get('JOURNAL_WORKER_PROMPT') + with ctx.lock(): + lease = get_lease(ctx, inp) + lease['prompt_seq'] = (lease.get('prompt_seq') or 0) + 1 + first = not lease.get('prompted') + if first: + lease['prompted'] = True + try: + dispatched = bool(worker_rx) and re.search(worker_rx, prompt, re.I | re.S) + except re.error: + dispatched = False + if lease['role_source'] == 'default' and dispatched and not LEAD_OVERRIDE.search(prompt): + lease['role'], lease['role_source'] = 'worker', 'dispatch-prompt' + texts.append('Journal: this prompt is a dispatched task, so this session counts as a WORKER and is ' + 'not nudged about journals. Write a journal only if your task asks for one; otherwise ' + 'your report to the lead is the record.') + ctx.trace(ev='role', sid=lease['sid'], role='worker', source='dispatch-prompt') + news = unseen_ledger(ctx, lease) if lease['role'] != 'worker' else [] + ctx.save_lease(lease) + if first and lease['role'] != 'worker' and not lease.get('journal'): + rel = related(ctx, lease, prompt, exclude=set(lease.get('shown') or [])) + if rel: + texts.append('Journals that match this first prompt (in addition to the start-up candidates):\n' + + '\n'.join(fmt_card(ctx, c, o) for c, o in rel)) + if news: + texts.append('Journal: worker results landed since you last looked (record the outcome in YOUR journal, ' + 'one line each, when you act on them):\n' + '\n'.join(news)) + if not texts: + return None + return {'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit', 'additionalContext': '\n'.join(texts)}} + + +def ledger(ctx): + p = os.path.join(ctx.sdir, 'worker-ledger.jsonl') + out = [] + for line in (read(p) or '').splitlines(): + try: + out.append(json.loads(line)) + except Exception: + pass + return out + + +def unseen_ledger(ctx, lease): + seen = lease.get('ledger_seen') or lease.get('started') or 0 + rows = [e for e in ledger(ctx) if e.get('t', 0) > seen and e.get('sid') != lease['sid']] + if rows: + lease['ledger_seen'] = max(e['t'] for e in rows) + return [f" - worker {e['sid'][:8]} ({e.get('worktree')}/{e.get('branch')}): {e.get('head') or 'no commit'} | " + f"{e.get('summary', '')[:180]}" for e in rows[-8:]] + + +def deny(reason): + return {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'deny', + 'permissionDecisionReason': reason}} + + +def tool_text(name, ti): + """The new text a tool call puts into a file.""" + if name == 'apply_patch': + return patch_added_text(str(ti.get('command') or ti.get('patch') or ti.get('input') or '')) + if name == 'Bash': + return str(ti.get('command', '')) + return ti.get('content') or ti.get('new_string') or ti.get('new_source') or \ + '\n'.join(e.get('new_string', '') for e in ti.get('edits') or [] if isinstance(e, dict)) + + +def tool_may_matter(event, inp): + """Fast path: decide without git or file I/O whether this tool call can matter to the journal hooks.""" + tool, ti = inp.get('tool_name', ''), inp.get('tool_input') or {} + tail = os.path.basename(os.path.normpath(os.environ.get('SESSION_JOURNAL_DIR') or 'sessions')) + '/' + blob = json.dumps(ti) if isinstance(ti, dict) else str(ti) + if tail in blob.replace('\\\\', '/').replace('\\/', '/'): + return True + if event == 'PostToolUse': # landed work is recorded for runtimes without a usable transcript + if tool in LANDING_TOOLS: + return True + if tool == 'Bash': + cmd = str((ti or {}).get('command', '')) + extra = os.environ.get('JOURNAL_LANDING_BASH') + return bool(COMMIT_BASH.search(cmd) or (extra and re.search(extra, cmd))) + return False + + +def same_repo_worktree_journal_dir(ctx, path): + """True when path is //; other repositories are not ours.""" + d = os.path.dirname(os.path.abspath(path)) + while d and not os.path.isdir(d): + d = os.path.dirname(d) + common = git('rev-parse', '--git-common-dir', cwd=d) + return bool(common) and bool(ctx.common) and \ + os.path.realpath(os.path.join(d, common)) == os.path.realpath(ctx.common) + + +def pending_path(ctx, inp): + key = re.sub(r'[^A-Za-z0-9_-]', '', (inp.get('session_id') or 'nosid') + '-' + (inp.get('tool_use_id') or 'x')) + return os.path.join(ctx.sdir, 'pending', key) + + +def in_jdir(ctx, path): + return os.path.realpath(os.path.dirname(os.path.join(ctx.cwd, path))) == os.path.realpath(ctx.jdir) + + +def on_pre_tool(ctx, inp): + tool, ti = inp.get('tool_name', ''), inp.get('tool_input') or {} + names, paths = [], [] + if tool in LANDING_TOOLS: + if tool == 'apply_patch': + paths = [p for p in patch_paths(str(ti.get('command') or ''), ctx.cwd) if ctx.journal_name(p)] + else: + p = ti.get('file_path') or ti.get('notebook_path') or '' + paths = [p] if ctx.journal_name(p) else [] + names = [ctx.journal_name(p) for p in paths] + elif tool == 'Bash': + names = ctx.bash_journal_writes(str(ti.get('command', '')), cwd=ctx.cwd) + try: # PostToolUse compares journal mtimes against this to catch variable-path writes + with open(pending_path(ctx, inp), 'w') as fh: + fh.write(str(time.time())) + except OSError: + pass + if not names: + return None + for p in paths: + if not in_jdir(ctx, p): + if same_repo_worktree_journal_dir(ctx, p): + return deny(f'Journals live only in the main checkout: write {ctx.jpath(ctx.journal_name(p))} instead ' + f'of {p} (a linked worktree has its own stale copy of {ctx.jrel}/).') + return None # another repository's file that happens to look like a journal + lease = ctx.lease(inp.get('session_id') or 'nosid') or new_lease(ctx, inp) + if hard_worker(lease): + ctx.trace(ev='deny', sid=lease['sid'], why='worker', journal=names[0]) + return deny(f'This session is a worker (JOURNAL_ROLE=worker): do not write session journals. Put results in ' + f'your report to the lead. If this session should keep a journal, run ' + f'`python3 {ctx.script} role lead` and retry.') + for jn in names: + owners = ctx.owners(jn, me=lease) + if owners: + o = owners[0] + ctx.trace(ev='deny', sid=lease['sid'], why='in-use', journal=jn, owner=o['sid']) + return deny(f"{ctx.show(jn)} is in use by live session {o['sid'][:8]} (worktree {o.get('worktree')}, " + f"branch {o.get('branch')}). Create your own journal and add '**Continues:** {ctx.show(jn)}' " + f"to link it. (If that session is gone, run `python3 {ctx.script} release {o['sid'][:8]}`.)") + if ctx.secrets == 'deny': + hits = scan_secrets(tool_text(tool, ti)) + if hits: + ctx.trace(ev='deny', sid=lease['sid'], why='secret', journal=names[0], kinds=[h[1] for h in hits]) + return deny(f'The text for {ctx.show(names[0])} looks like it contains a credential ({fmt_hits(hits)} of ' + 'the new text; values are not shown). Journals are committed and long-lived: write where the ' + 'secret lives instead (a vault or secret name, an env-var reference, or ). If a ' + f'human has verified a line carries no credential, append {SKIP_OK} to that line.') + return None + + +def on_post_tool(ctx, inp): + tool, ti = inp.get('tool_name', ''), inp.get('tool_input') or {} + notes = [] + names, files, cmd = tool_targets(ctx, tool, ti, cwd=ctx.cwd) + if tool in LANDING_TOOLS: + paths = patch_paths(str(ti.get('command') or ''), ctx.cwd) if tool == 'apply_patch' else \ + [ti.get('file_path') or ti.get('notebook_path') or ''] + names = [ctx.journal_name(p) for p in paths if ctx.journal_name(p) and in_jdir(ctx, p)] + elif cmd is not None: + t0 = None + try: + p = pending_path(ctx, inp) + with open(p) as fh: + t0 = float(fh.read().strip() or 0) + os.remove(p) + except (OSError, ValueError): + pass + if t0: + extra = [n for n in journals_changed_since(ctx, t0) if n not in names] + if len(extra) > 2: # a bulk rewrite of many journals binds nothing + ctx.trace(ev='bulk-journal-change', sid=inp.get('session_id'), n=len(extra)) + extra = [] + names = names + extra + names = [n for n in dict.fromkeys(names) if n] + landed = bool(files) or bool(cmd and ctx.landing_bash.search(cmd)) + if not names and not landed: + return None + with ctx.lock(): + lease = get_lease(ctx, inp) + key = turn_key(inp, lease) + turn = turn_record(lease, key) + if landed: + turn['landed'] = True + turn['files'] = (turn['files'] + [f for f in files if f])[-20:] + if cmd and COMMIT_BASH.search(cmd): + turn['committed'] = True + turn['commits'] = (turn['commits'] + [re.sub(r'\s+', ' ', cmd)[:160]])[-5:] + if names and hard_worker(lease): + ctx.save_lease(lease) + return {'hookSpecificOutput': {'hookEventName': 'PostToolUse', 'additionalContext': + f"You are a worker (JOURNAL_ROLE=worker) and just changed {ctx.show(names[-1])}. Revert that " + "change; report to your lead instead."}} + for jn in names: + owners = ctx.owners(jn, me=lease) + if owners: + notes.append(f"{ctx.show(jn)} is in use by live session {owners[0]['sid'][:8]}; move what you just " + f"wrote into your own journal (with '**Continues:** {ctx.show(jn)}') and revert it there.") + free = [jn for jn in names if not ctx.owners(jn, me=lease)] + if free: + bind(ctx, lease, free[-1], 'write') + lease['last_write'] = time.time() + lease['writes'] = (lease.get('writes') or [])[-50:] + [[free[-1], time.time(), key]] + lease['unjournaled_turns'] = 0 + turn['journals'] = sorted(set(turn['journals']) | set(free)) + c = card(ctx, free[-1]) + if c and c['bytes'] > ctx.max_bytes and lease.get('size_warned') != free[-1]: + lease['size_warned'] = free[-1] + notes.append(f"{ctx.show(free[-1])} is {c['bytes'] // 1024} KB. Finish this update, then continue the " + f"work in a new journal whose header says '**Continues:** {ctx.show(free[-1])}'.") + ctx.save_lease(lease) + if names and ctx.secrets == 'warn': + hits = scan_secrets(tool_text(tool, ti)) + if hits: + ctx.trace(ev='secret-warning', sid=lease['sid'], journal=names[-1], kinds=[h[1] for h in hits]) + notes.append(f"The text you just wrote to {ctx.show(names[-1])} looks like it contains a credential " + f"({fmt_hits(hits)}). Replace the value with where the secret lives.") + if notes: + return {'hookSpecificOutput': {'hookEventName': 'PostToolUse', 'additionalContext': '\n'.join(notes)}} + return None + + +def on_stop(ctx, inp): + sid = inp.get('session_id') or 'nosid' + pre = ctx.lease(sid) or {} + f = turn_facts(ctx, read_transcript(inp.get('transcript_path')), floor=pre.get('bind_floor') or 0) + nudge = None + with ctx.lock(): + lease = get_lease(ctx, inp) + key = turn_key(inp, lease, fallback=f['turn_start_ts']) + t0 = iso_epoch(f['turn_start_ts'] or '') - 2 + turn = turn_record(lease, key) + # bind from the transcript too (Bash/python writes), never onto a live owner's journal + if f['session_journal_writes'] and not hard_worker(lease): + jn = f['session_journal_writes'][-1] + if not ctx.owners(jn, me=lease): + bind(ctx, lease, jn, 'transcript') + if f['last_journal_write_ts']: + lease['last_write'] = max(lease.get('last_write') or 0, iso_epoch(f['last_journal_write_ts'])) + hook_writes = [w[0] for w in lease.get('writes') or [] + if (len(w) > 2 and w[2] == key) or (key == f['turn_start_ts'] and t0 > 0 and w[1] >= t0)] + touched = set(f['turn_journal_writes']) | set(hook_writes) | set(turn['journals']) + landed = f['landed'] or turn['landed'] + committed = f['committed'] or turn['committed'] + if touched: + lease['unjournaled_turns'] = 0 + elif landed and lease.get('counted') != key: + lease['unjournaled_turns'] = lease.get('unjournaled_turns', 0) + 1 # once per turn, not per Stop round + lease['counted'] = key + if lease['role'] == 'worker' and landed and not inp.get('stop_hook_active') and lease.get('ledgered') != key: + entry = {'t': time.time(), 'sid': lease['sid'], 'worktree': lease.get('worktree'), + 'branch': lease.get('branch'), 'task': (f['first_prompt'] or '')[:200], + 'head': git('log', '-1', '--format=%h %s', cwd=ctx.root) if committed else None, + 'commits': (f['turn_commits'] or turn['commits'])[-5:], + 'files': sorted({os.path.relpath(x, ctx.main) if x.startswith(ctx.main) else x + for x in f['turn_files'] + turn['files'] if x})[:20], + 'summary': re.sub(r'\s+', ' ', inp.get('last_assistant_message') or f['last_text'])[:400]} + with open(os.path.join(ctx.sdir, 'worker-ledger.jsonl'), 'a') as fh: + fh.write(json.dumps(entry) + '\n') + lease['ledgered'] = key + why = None + if not landed: + result = 'readonly' + elif touched: + result = 'fresh' + elif lease['role'] == 'worker': + result, why = 'skip', 'worker' + elif skipping(lease): + result, why = 'skip', 'skip' + elif inp.get('stop_hook_active') or lease.get('nudged') == key: + result, why = 'skip', 'already-nudged' + else: + since = (time.time() - (lease.get('last_write') or lease.get('started') or time.time())) / 60 + due = committed or not lease.get('journal') or lease.get('unjournaled_turns', 0) >= 2 or \ + since >= ctx.nudge_min + if not due: + result = 'debounced' + else: + result = 'nudge' + lease['nudged'] = key + nudge = (lease.get('journal'), committed, lease.get('unjournaled_turns', 0), since) + ctx.save_lease(lease) + ctx.trace(ev='stop', sid=sid, result=result, why=why, journal=lease.get('journal'), + unjournaled=lease.get('unjournaled_turns')) + if not nudge: + return None + journal, committed, unjournaled, since = nudge + if not journal: + rows = candidates(ctx, lease, 3) + msg = (f"SESSION-JOURNAL: this turn landed work but this session has no journal yet. Create " + f"{ctx.jdir}/YYYY-MM-DD-HHMM-.md (local time) from {ctx.jrel}/TEMPLATE.md, or append to a " + f"candidate it truly continues (never one marked IN USE). If this session needs no journal, say so " + f"and stop." + + (f"\n{fmt_candidates(ctx, rows)}" if rows else '')) + else: + c = card(ctx, journal) or {} + pointer = c.get('pointer', 'none') + why = 'a commit or push landed' if committed else \ + f"{unjournaled} landed turns / {since:.0f} min since its last update" + if is_thin(pointer): + msg = (f"SESSION-JOURNAL (THIN): add one milestone line to YOUR journal {ctx.jpath(journal)} ({why}) that " + f"points at {pointer.strip('`- ')} (section, version or commit), and bump 'Last updated'; change " + "'Where we are now' or 'Next step' only if they moved. Do not re-narrate the plan.") + else: + msg = (f"SESSION-JOURNAL: update YOUR journal {ctx.jpath(journal)} ({why}). DETAILED mode: milestones, " + "evidence, commits, files touched. Refresh 'Last updated', 'Where we are now' and 'Next step for " + "a fresh agent'.") + return {'decision': 'block', 'reason': msg} + + +def stamp_session_line(ctx, name, lease, status=None): + """Hook-managed header lines, written only when no live session is editing the journal.""" + p = ctx.jpath(name) + text = read(p) + if text is None: + return + tag = f"{lease['sid'][:8]} ({lease.get('worktree', '?')}/{lease.get('branch', '?')}, {lease.get('runtime') or 'agent'})" + new = text + sessions = header(new, 'Sessions') + if lease['sid'][:8] not in sessions: + if sessions: + new = re.sub(r'^(\*\*Sessions:\*\*\s*)(.+)$', lambda m: m.group(1) + m.group(2) + ', ' + tag, new, count=1, + flags=re.M) + else: + new = re.sub(r'^(\*\*Status:\*\*.*)$', lambda m: m.group(1) + '\n**Sessions:** ' + tag, new, count=1, + flags=re.M) + if status and header(new, 'Status').upper().startswith('ACTIVE'): + new = re.sub(r'^(\*\*Status:\*\*\s*)ACTIVE\b.*$', lambda m: m.group(1) + status, new, count=1, + flags=re.M | re.I) + if new != text: + tmp = f'{p}.{os.getpid()}.tmp' + with open(tmp, 'w') as fh: + fh.write(new) + os.replace(tmp, p) + + +def on_session_end(ctx, inp): + reason = inp.get('reason') or 'other' + with ctx.lock(): + lease = ctx.lease(inp.get('session_id') or 'nosid') + if not lease: + return None + lease['ended'] = time.time() + lease['end_reason'] = reason + ctx.save_lease(lease) + if lease.get('journal') and lease['role'] != 'worker' and not ctx.owners(lease['journal']): + # /clear starts a new session in the same window, which usually carries on: record it, keep the status. + pause = reason != 'clear' and os.environ.get('JOURNAL_PAUSE_ON_END', '1') not in ('0', 'false', 'no') + stamp_session_line(ctx, lease['journal'], lease, + status='PAUSED (auto: session ended without DONE)' if pause else None) + ctx.trace(ev='SessionEnd', sid=lease['sid'], journal=lease.get('journal'), reason=reason) + return None + + +HANDLERS = {'SessionStart': on_session_start, 'UserPromptSubmit': on_user_prompt, 'PreToolUse': on_pre_tool, + 'PostToolUse': on_post_tool, 'Stop': on_stop, 'SessionEnd': on_session_end} + + +def run_hook(event): + out = None + try: + if os.environ.get('SESSION_JOURNAL_DISABLE') not in (None, '', '0') or event not in HANDLERS: + return 0 + raw = sys.stdin.read() if not sys.stdin.isatty() else '' + try: + inp = json.loads(raw) if raw.strip() else {} + except Exception: + inp = {} + if not isinstance(inp, dict): + inp = {} + if event in ('PreToolUse', 'PostToolUse') and not tool_may_matter(event, inp): + return 0 # most tool calls: no git, no file I/O + ctx = None + try: + ctx = Ctx(inp.get('cwd')) + if not ctx.enabled: + return 0 + out = HANDLERS[event](ctx, inp) + except Exception as e: # a journal hook must never break the session + if ctx is not None and ctx.enabled: + ctx.trace(ev='error', event=event, err=repr(e)[:300]) + out = None + return 0 + finally: + if out: + print(json.dumps(out)) + elif event == 'Stop': + print('{}') # Codex requires JSON on stdout from a Stop hook that exits 0 + + +# ---------------------------------------------------------------- CLI + +def transcripts_for(ctx, sid_prefix): + for l in ctx.leases(): + if l['sid'].startswith(sid_prefix) and l.get('transcript') and os.path.exists(l['transcript']): + return l['transcript'] + root = os.environ.get('JOURNAL_TRANSCRIPTS_DIR') or os.path.expanduser('~/.claude/projects') + hits = glob.glob(os.path.join(root, '*', sid_prefix + '*.jsonl')) + return hits[0] if hits else None + + +def session_brief(ctx, path, max_prompts=6): + entries = read_transcript(path) + prompts, files, commits, last = [], [], [], '' + for d in entries: + if is_human(d): + c = (d.get('message') or {}).get('content') + t = c if isinstance(c, str) else ' '.join(x.get('text', '') for x in c or [] if isinstance(x, dict)) + prompts.append((d.get('timestamp', '')[:16], re.sub(r'\s+', ' ', t)[:200])) + if d.get('type') == 'assistant' and not d.get('isSidechain'): + for c in (d.get('message') or {}).get('content') or []: + if isinstance(c, dict) and c.get('type') == 'tool_use': + _, fs, cmd = tool_targets(ctx, c.get('name', ''), c.get('input') or {}) + files += fs + if cmd and COMMIT_BASH.search(cmd): + commits.append(re.sub(r'\s+', ' ', cmd)[:160]) + elif isinstance(c, dict) and c.get('type') == 'text' and c.get('text'): + last = c['text'] + return {'first': entries[0].get('timestamp', '') if entries else '', + 'last': entries[-1].get('timestamp', '') if entries else '', 'prompts': prompts[:max_prompts], + 'n_prompts': len(prompts), 'files': sorted(set(files)), 'commits': commits[-8:], + 'last_text': re.sub(r'\s+', ' ', last)[:600]} + + +def norm_name(ctx, name): + base = os.path.basename(name.rstrip('/')) + return base if NAME.match(base) else None + + +def cmd_reconstruct(ctx, name): + name = norm_name(ctx, name) or name + c = card(ctx, name) + if not c: + print(f'no such journal: {name}') + return 1 + text = read(ctx.jpath(name)) or '' + sids = set(re.findall(r'\b([0-9a-f]{8})\b', c['sessions'])) + sids |= {l['sid'][:8] for l in ctx.leases() if l.get('journal') == name or name in (l.get('history') or [])} + print(f"# Reconstruct {name}\n{c['title']} | status {c['status']} | updated {c['updated']} | {c['bytes']} bytes") + print(f"Plan pointer: {c['pointer']}\nContinues: {header(text, 'Continues') or '-'}\n\n## Next step (journal)\n" + f"{section(text, 'Next step for a fresh agent')}\n") + print('## Where we are now (journal)\n' + section(text, 'Where we are now') + '\n') + for s in sorted(sids): + tp = transcripts_for(ctx, s) + if not tp: + print(f'## Session {s}: transcript not found') + continue + b = session_brief(ctx, tp) + print(f"## Session {s} ({b['first'][:16]} -> {b['last'][:16]}, {b['n_prompts']} prompts)\nTranscript: {tp}") + for ts, p in b['prompts']: + print(f' - [{ts}] {p}') + if b['commits']: + print(' commits: ' + ' | '.join(b['commits'])) + if b['files']: + print(' files: ' + ', '.join(os.path.relpath(f, ctx.main) if f.startswith(ctx.main) else f + for f in b['files'][:25])) + print(f" last reply: {b['last_text']}\n") + later = [os.path.basename(p) for p in ctx.journals() if name in header(read(p) or '', 'Continues')] + if later: + print('## Continued by\n' + '\n'.join(' - ' + ctx.show(x) for x in later)) + return 0 + + +def cmd_find(ctx, words): + q = [w.lower() for w in words] + if not q: + print('usage: find ') + return 1 + rows = [] + for p in ctx.journals(): + low = (read(p) or '').lower() + score = sum(low.count(w) for w in q) + 5 * sum(w in os.path.basename(p).lower() for w in q) + if score and all(w in low for w in q): + rows.append((score, card(ctx, os.path.basename(p)))) + rows.sort(key=lambda r: (-r[0], -r[1]['mtime'])) + for s, c in rows[:15]: + print(f"{s:4d} {ctx.show(c['journal'])} [{c['label']}] {c['title'][:60]}\n next: {c['next'][:150]}") + wl = [e for e in ledger(ctx) if all(w in json.dumps(e).lower() for w in q)] + if wl: + print('\nWorker ledger (dispatched work that has no journal of its own):') + for e in wl[-10:]: + print(f" {dt.datetime.fromtimestamp(e['t']):%Y-%m-%d %H:%M} worker {e['sid'][:8]} " + f"{e.get('worktree')}/{e.get('branch')}: {e.get('head') or '-'} | {e.get('summary', '')[:150]}") + if not rows and not wl: + print('no journal matches') + return 0 + + +def caller_sid(): + return os.environ.get('SESSION_JOURNAL_SESSION_ID') or os.environ.get('CLAUDE_CODE_SESSION_ID') or \ + os.environ.get('CODEX_THREAD_ID') or '' + + +def cmd_candidates(ctx, words): + """The SessionStart view on demand: this session's bound journal, then open candidates (IN USE marked), optionally + only those whose text contains every word. Use it instead of picking the newest file by name or mtime.""" + sid = caller_sid() + lease = next((x for x in ctx.leases() if sid and x['sid'] == sid), None) or \ + {'sid': sid or 'cli', 'worktree': ctx.worktree, 'branch': ctx.branch} + if lease.get('journal') and lease.get('role') != 'worker': + print(f"bound: {ctx.show(lease['journal'])}") + q = [w.lower() for w in words] + rows = candidates(ctx, lease, limit=500 if q else 5) + if q: + rows = [r for r in rows if all(w in (read(ctx.jpath(r[1]['journal'])) or '').lower() for w in q)][:5] + print(fmt_candidates(ctx, rows) if rows else 'no open candidates' + (' match' if q else '')) + return 0 + + +def cmd_list(ctx, args): + now = time.time() + for l in sorted(ctx.leases(), key=lambda x: -(x.get('heartbeat') or 0)): + if '--all' not in args and not ctx.is_live(l): + continue + print(f"{l['sid'][:8]} {'LIVE' if ctx.is_live(l) else 'ended'} {l.get('role', '?'):6} " + f"{str(l.get('worktree')):>18}/{str(l.get('branch')):<22} hb {int((now - (l.get('heartbeat') or 0)) / 60)}m" + f" journal={l.get('journal') or '-'}") + return 0 + + +def health(ctx): + """Drift indicators: run it on a schedule, or before trusting the hooks.""" + leases = ctx.leases() + by_j = {} + for l in leases: + if l.get('journal'): + by_j.setdefault(l['journal'], set()).add(l['sid']) + cards = [c for c in (card(ctx, os.path.basename(p)) for p in ctx.journals()) if c] + stale_active = [c['journal'] for c in cards if c['status'] == 'ACTIVE' and time.time() - c['mtime'] > 7 * 86400 + and not ctx.owners(c['journal'])] + secrets = [] + for c in cards: + h = scan_secrets(read(ctx.jpath(c['journal']))) + if h: + secrets.append((c['journal'], sorted({x[1] for x in h}))) + ev = [] + for x in (read(os.path.join(ctx.sdir, 'events.jsonl')) or '').splitlines(): + try: + ev.append(json.loads(x)) + except Exception: + pass + + def cnt(**kw): + return sum(all(e.get(k) == v for k, v in kw.items()) for e in ev) + + landed = {e.get('sid') for e in ev if e.get('ev') == 'stop' and e.get('result') in ('nudge', 'debounced', 'fresh', 'skip')} + return { + 'version': VERSION, 'journal_dir': ctx.jdir, 'journals': len(cards), + 'open': sum(c['status'] not in CLOSED for c in cards), 'stale_active_7d': len(stale_active), + 'oversized': sum(c['bytes'] > ctx.max_bytes for c in cards), + 'multi_session_journals': sum(len(v) > 1 for v in by_j.values()), + 'secret_journals': len(secrets), 'secret_examples': secrets[:10], + 'leases': len(leases), 'live_sessions': sum(ctx.is_live(l) for l in leases), + 'workers': sum(l.get('role') == 'worker' for l in leases), + 'nudges': cnt(ev='stop', result='nudge'), 'debounced': cnt(ev='stop', result='debounced'), + 'fresh_turns': cnt(ev='stop', result='fresh'), + 'nudges_per_landed_session': round(cnt(ev='stop', result='nudge') / max(len(landed), 1), 2), + 'deny_in_use': cnt(ev='deny', why='in-use'), 'deny_worker': cnt(ev='deny', why='worker'), + 'deny_secret': cnt(ev='deny', why='secret'), 'secret_warnings': cnt(ev='secret-warning'), + 'clear_handoffs': sum(1 for e in ev if e.get('ev') == 'SessionStart' and e.get('predecessor')), + 'hook_errors': cnt(ev='error'), + } + + +def cmd_scan(ctx, paths): + """Secret scan of the given files, or of every journal. Prints line numbers and rule names, never values.""" + files = paths or ctx.journals() + bad = 0 + for p in files: + hits = scan_secrets(read(p) or '') + if hits: + bad += 1 + print(f'{p}: {fmt_hits(hits)}') + print(f'scan: {bad} of {len(files)} file(s) carry credential-shaped content') + return 1 if bad else 0 + + +def last_commit_dates(ctx): + """Latest commit time per journal in one git log pass (checkouts and clones reset mtimes).""" + out, cur = {}, None + for line in git('log', '--format=@%ct', '--name-only', '--', ctx.jdir, cwd=ctx.main, timeout=120).splitlines(): + if line.startswith('@'): + cur = int(line[1:]) + elif line.strip() and cur: + out.setdefault(os.path.basename(line.strip()), cur) + return out + + +def header_epoch(value): + m = re.search(r'(\d{4}-\d\d-\d\d)[ T](\d\d):(\d\d)', value or '') + if not m: + return 0 + try: + return time.mktime(time.strptime(f'{m.group(1)} {m.group(2)}:{m.group(3)}', '%Y-%m-%d %H:%M')) + except (ValueError, OverflowError): + return 0 + + +def cmd_sweep(ctx, args): + """End dead leases, drop ended leases older than 30 days, and PAUSE ACTIVE journals nobody live owns that went + quiet (default 24 h). --dry-run prints what would change. --by-git dates activity by last commit and the + 'Last updated' header (plus mtime when the journal is dirty) instead of checkout mtimes.""" + dry, by_git = '--dry-run' in args, '--by-git' in args + nums = [a for a in args if not a.startswith('--')] + try: + hours = float(nums[0]) if nums else 24 + except ValueError: + print('usage: sweep [hours] [--dry-run] [--by-git]') + return 1 + now, changes = time.time(), [] + for l in ctx.leases(): + if l.get('ended') and now - l['ended'] > 30 * 86400: + changes.append(f"drop lease {l['sid'][:8]} (ended {int((now - l['ended']) / 86400)}d ago)") + if not dry: + with contextlib.suppress(OSError): + os.remove(ctx.lease_path(l['sid'])) + elif not l.get('ended') and not ctx.is_live(l) and now - (l.get('heartbeat') or 0) > 6 * 3600: + changes.append(f"end lease {l['sid'][:8]} (journal {l.get('journal') or '-'})") + if not dry: + with ctx.lock(): + l['ended'], l['end_reason'] = now, 'sweep-timeout' + ctx.save_lease(l, touch=False) + if l.get('journal') and not ctx.owners(l['journal']): + stamp_session_line(ctx, l['journal'], l) + gdates = last_commit_dates(ctx) if by_git else {} + if by_git and not gdates and git('ls-files', '--', ctx.jdir, cwd=ctx.main): + print('sweep --by-git: git log over the journal directory returned nothing (timeout or error); refusing to ' + 'judge idleness by checkout mtimes. Re-run, or run without --by-git.') + return 2 + dirty = {os.path.basename(x[3:].strip()) for x in git('status', '--porcelain', '--', ctx.jdir, + cwd=ctx.main).splitlines()} if by_git else set() + agent = {} + for l in ctx.leases(): + for w in l.get('writes') or []: + agent[w[0]] = max(agent.get(w[0], 0), w[1]) + for p in ctx.journals(): + c = card(ctx, os.path.basename(p)) + if not c or c['status'] != 'ACTIVE' or ctx.owners(c['journal']): + continue + n = c['journal'] + base = gdates.get(n, 0) if by_git else c['mtime'] + if by_git and (n in dirty or not gdates.get(n)): + base = max(base, c['mtime']) + act = max(base, header_epoch(c['updated']), agent.get(n, 0)) + if now - act > hours * 3600: + changes.append(f'pause {ctx.show(n)} (idle {int((now - act) / 86400)}d)') + if not dry: + text = read(p) or '' + text = re.sub(r'^(\*\*Status:\*\*\s*)ACTIVE\b.*$', + lambda m: m.group(1) + f'PAUSED (auto: idle >{hours:.0f}h)', text, count=1, flags=re.M) + tmp = f'{p}.{os.getpid()}.tmp' + with open(tmp, 'w') as fh: + fh.write(text) + os.replace(tmp, p) + for ch in changes: + print((' would ' if dry else ' ') + ch) + print(f"sweep{' (dry run)' if dry else ''}: {len(changes)} changes") + return 0 + + +USAGE = ('commands: candidates [words..] | find | reconstruct | list [--all] | health | ' + 'scan [files..] | sweep [hours] [--dry-run] [--by-git] | whoami [sid] | bound [sid] | ' + 'bind [sid] [--force] | release | role lead|worker [sid] | skip [sid] | version\n' + '(sid defaults to $CLAUDE_CODE_SESSION_ID or $CODEX_THREAD_ID; set SESSION_JOURNAL_SESSION_ID to override)') + + +def main(argv): + if len(argv) >= 2 and argv[0] == 'hook': + return run_hook(argv[1]) + cmd, args = (argv[0] if argv else 'help'), argv[1:] + if cmd in ('version', '--version'): + print(VERSION) + return 0 + if cmd in ('help', '-h', '--help'): + print(__doc__) + print(USAGE) + return 0 + ctx = Ctx() + if not ctx.enabled: # run from elsewhere: use the project this script is installed in + ctx = Ctx(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + if not ctx.enabled: + print(f'no journal directory at {ctx.jdir}') + return 1 + sid_arg = lambda a: (a[0] if a else '') or caller_sid() + find_lease = lambda sid: next((x for x in ctx.leases() if sid and x['sid'].startswith(sid)), None) + if cmd == 'list': + return cmd_list(ctx, args) + if cmd == 'whoami': + l = find_lease(sid_arg(args)) + print(json.dumps(l, indent=1) if l else 'unknown session (no lease; pass a session id prefix)') + return 0 + if cmd == 'bound': + l = find_lease(sid_arg(args)) + if l and l.get('journal') and l.get('role') != 'worker': + print(ctx.jpath(l['journal'])) + return 0 + if cmd == 'find': + return cmd_find(ctx, args) + if cmd == 'candidates': + return cmd_candidates(ctx, args) + if cmd == 'reconstruct' and args: + return cmd_reconstruct(ctx, args[0]) + if cmd == 'health': + print(json.dumps(health(ctx), indent=1)) + return 0 + if cmd == 'scan': + return cmd_scan(ctx, args) + if cmd == 'sweep': + return cmd_sweep(ctx, args) + if cmd == 'release' and args: + with ctx.lock(): + for l in ctx.leases(): + if l['sid'].startswith(args[0]): + l['ended'], l['end_reason'] = time.time(), 'released' + ctx.save_lease(l, touch=False) + print('released', l['sid']) + return 0 + if cmd in ('role', 'skip'): + if cmd == 'role' and not (args and args[0] in ('lead', 'worker')): + print('usage: role lead|worker [sid]') + return 1 + rest = args[1:] if cmd == 'role' else args + with ctx.lock(): + l = find_lease(sid_arg(rest)) + if not l: + print('unknown session (pass a session id prefix)') + return 1 + if cmd == 'role': + l['role'], l['role_source'] = args[0], 'cli' + else: + l['skip'] = True + ctx.save_lease(l) + print(f"{l['sid'][:8]} " + (f'role={args[0]}' if cmd == 'role' else 'skip: no journal nudges this session')) + return 0 + if cmd == 'bind' and args: + plain = [a for a in args if not a.startswith('--')] + sid, jn = (caller_sid(), plain[0]) if len(plain) == 1 else (plain[0], plain[1]) + jn = norm_name(ctx, jn) + if not sid or not jn: + print('usage: bind [] (sid defaults to the caller session id)') + return 1 + with ctx.lock(): + l = find_lease(sid) or {'sid': sid, 'role': 'lead', 'role_source': 'default', 'worktree': ctx.worktree, + 'branch': ctx.branch, 'history': [], 'writes': [], 'started': time.time()} + owners = ctx.owners(jn, me=l) + if owners and '--force' not in args: + print(f"{ctx.show(jn)} is in use by live session {owners[0]['sid'][:8]}; use --force to bind anyway") + return 1 + bind(ctx, l, jn, 'manual') + ctx.save_lease(l) + return 0 + print(USAGE) + return 1 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/.claude/settings.json b/.claude/settings.json index 5d84ce3..0afbf76 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,8 @@ "hooks": [ { "type": "command", - "command": "\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.claude/hooks/session-journal-pointer.sh\"" + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook SessionStart || true", + "timeout": 15 } ] } @@ -16,7 +17,54 @@ "hooks": [ { "type": "command", - "command": "\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.claude/hooks/session-journal-nudge.sh\"" + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook Stop || true", + "timeout": 30 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook UserPromptSubmit || true", + "timeout": 10 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook PreToolUse || true", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook PostToolUse || true", + "timeout": 10 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}/.claude/hooks/session-journal.py\" hook SessionEnd || true", + "timeout": 5 } ] } diff --git a/ADOPT.md b/ADOPT.md index 0851b17..458d8a5 100644 --- a/ADOPT.md +++ b/ADOPT.md @@ -1,76 +1,129 @@ # Agent Adoption Contract -You are installing the Session Journal discipline into a target repository. Adapt it to the repository you actually find. Preserve existing configuration and prove the result before finishing. +You are installing the Session Journal discipline into a target repository. Adapt it to the repository you actually +find, preserve existing configuration, and prove the result before finishing. + +**Read `skills/session-journal/SKILL.md` first and follow it.** The skill holds the judgment calls: + +- where journals live; +- how to adapt the setup to this repository's agents and orchestration; +- lead and worker roles; +- migrating old journals; +- what may never go in a journal. + +This contract adds the order of work, the question budget, and the proof you must report. Ask at most three questions, and only when discovery cannot answer them: -1. Which journal directory should be used, if both root `sessions/` and `docs/sessions/` are equally plausible? +1. Which journal directory should be used, if root `sessions/` and `docs/sessions/` are equally plausible? 2. Should journals be committed (the default) or ignored locally? 3. Should the optional spine-promotion nudge be installed? ## Phase A — survey without writing -Inspect and record for your final report: - -1. Agent runtimes: `.claude/settings.json`, `.codex/config.toml`, `.codex/hooks.json`, and instruction-only runtimes such as Cursor or Copilot. Treat user-level configuration as a clue, never as permission to modify global files. -2. Instruction files: CLAUDE.md, AGENTS.md, both, or neither. Inventory existing hooks and plan a merge; never replace a hooks array. -3. Documentation topology: plans, specifications, ADRs, runbooks, or an issue tracker. Use the strongest active artifact as the default THIN pointer. With no docs culture, default to DETAILED and `none`. -4. Journal location: default to root `sessions/`; prefer `docs/sessions/` when the repository consistently keeps process docs under `docs/` or tightly controls published root files. A monorepo normally gets one workspace-root journal directory because a journal follows the human session, not a package. -5. Timezone: run `date +%Z` in the target repository and use that machine-local zone label. Do not use `date -u`, `UTC` by convention, or a zone inherited from this upstream repository. -6. Repository vocabulary: add branch lines for multi-repository work and name relevant issue, gate, ADR, or test identifiers in the local evidence guidance. -7. Ignore rules: journals are intended to be committed. Warn if an existing rule ignores the chosen directory, then ask Question 2 only if intent remains unclear. Check `.claude/` too — a repository that ignores it will silently give the hooks to nobody but the installing machine. -8. Repository root: the installer refuses to install into a subdirectory of a git repository, because the generated hook commands resolve their own root at runtime. Confirm you are targeting the root. - -Before writing, ask Question 3 unless the user's request already says whether to install the optional spine-promotion module. Do not infer opt-in or opt-out from repository shape. +Inspect the following and record what you find for your final report: + +1. **Agent runtimes.** Check `.claude/settings.json`, `.codex/hooks.json`, `.codex/config.toml`, and + instruction-only runtimes. Treat user-level configuration as a clue, never as permission to modify global + files. +2. **Instruction files.** Check for CLAUDE.md, AGENTS.md, both, or neither. Inventory existing hooks and plan a + merge; never replace a hooks array. +3. **Documentation.** Look for plans, specifications, ADRs, runbooks, or an issue tracker. Use the strongest active + artifact as the default THIN pointer. If the repository has no documentation habit, default to DETAILED and + `none`. +4. **Journal location.** Follow skill §1. + - A monorepo gets one journal directory at its root. + - A repository whose developers use linked worktrees still gets one directory: the hooks resolve it in the main + checkout. +5. **Existing journals or notes.** If the repository already has v1 journals or session notes elsewhere, plan the + migration in skill §6. +6. **Multi-agent setup.** If an orchestrator dispatches worker agents, find how a worker can be recognised: an + environment variable the orchestrator can set, or the shape of the dispatch prompt. Skill §2 and §4 turn that + into `JOURNAL_ROLE=worker` or `JOURNAL_WORKER_PROMPT`. +7. **Timezone.** Run `date +%Z` in the target repository and use that local label. +8. **Ignore rules.** Journals are meant to be committed. Warn if a rule ignores the chosen directory or `.claude/`. + Ask Question 2 only if the intent stays unclear. +9. **Repository root.** The installer refuses a subdirectory of a git repository. Target the root. +10. **Python.** Confirm `python3 --version` reports 3.8 or later wherever agents run. + +Before writing anything, ask Question 3, unless the user's request already says whether to install the optional +spine-promotion module. ## Phase B — install and adapt -1. Prefer the tested installer instead of rebuilding its merge logic. Clone or download this repository to a temporary location, then run `./install.sh ` with the discovered `--sessions-dir`, `--plan-pointer`, `--codex`, and/or `--no-claude` flags. If the installer cannot run, perform the equivalent steps below manually and explain why in the final report. -2. The installer puts one real copy of both scripts in `.claude/hooks/`, merges `SessionStart` and `Stop` without replacing existing groups, and uses runtime-relative commands so subdirectory starts and relocated clones work. It also writes `/README.md` and `/TEMPLATE.md`. Project-local Codex hooks require a trusted project and hook review through `/hooks`. -3. Pass `--plan-pointer ` when Phase A found a strong governing artifact, so the generated docs start in THIN mode rather than contradicting your own survey. -4. Adapt the generated sessions README with the strongest THIN default, the `date +%Z` result, and repository-specific evidence vocabulary. Adapt `TEMPLATE.md` if the repository needs extra sections. A later re-run of the installer preserves both; it will say so, and only `--refresh-docs` overwrites them. -5. Adapt the sentinel-delimited blocks in CLAUDE.md and AGENTS.md. Keep the twin-sync sentence only when both instruction files exist. Instruction-only runtimes need the manual end-of-turn check. A re-run preserves your edits inside the sentinels. -6. Set `JOURNAL_FRESH_SECS` in the generated hook commands only when the repository needs a non-default freshness window. A re-run preserves environment overrides you add here. -7. If Question 3 opted into spine promotion, merge `extras/spine-promotion/journal-spine-promotion-nudge.sh` as an additional Stop handler and test it. This optional handler is manually managed and must also be removed manually; the core installer intentionally owns only its pointer and freshness/secrets hooks. - -Never delete or rewrite an existing user hook. Never commit unless the user asked or repository conventions already authorize it. Never put a real or realistic credential in a demonstration journal. If a settings file contains comments or JSON5, do not normalize or overwrite it: show the matching snippet and ask the user to merge it manually. +1. Clone or download this repository to a temporary location. Then run `./install.sh ` with the discovered + `--sessions-dir`, `--plan-pointer`, `--codex`, and/or `--no-claude` flags. Do not rebuild its merge logic by hand. + If the installer cannot run, perform the equivalent steps manually from `hooks/*.snippet.json`, and explain why + in the report. +2. Adapt `/README.md` (the THIN default, the `date +%Z` result, the evidence vocabulary) and, if needed, + `TEMPLATE.md`. Adapt the sentinel blocks in CLAUDE.md and AGENTS.md. Keep the twin-sync sentence only when both + files exist. A re-run of the installer preserves all of these. +3. Add any environment prefixes from skill §2 to the generated hook commands. A re-run preserves them. +4. If Question 3 opted into spine promotion, merge `extras/spine-promotion/journal-spine-promotion-nudge.sh` as an + extra Stop handler and test it. It is managed by hand, and you must also remove it by hand. + +Never delete or rewrite an existing user hook. Never commit unless the user asked or repository conventions already +authorize it. Never put a real or realistic credential in a demonstration journal. If a settings file contains +comments or JSON5, do not normalize it: show the matching snippet and ask the user to merge it. ## Phase C — verify before finishing -Run `./doctor.sh ` and include its output in your report. It must exit 0 except for the "installation is committed" check, which Phase D resolves. It covers installation integrity, command resolution, journal-directory contents, git reachability, and a live pointer and Stop run. +Run `./doctor.sh ` and include its output. It must exit 0, apart from "the installation is committed", +which Phase D resolves. -Then run the behavioral cases the doctor cannot construct for you: +Then exercise the hook directly. Pipe JSON into `python3 .claude/hooks/session-journal.py hook ` from the +target root, using a throwaway `SESSION_JOURNAL_STATE_DIR`. For the cases that need landed work, also write a +two-line Claude Code transcript, and pass its path as `transcript_path`. | Check | Expected proof | | --- | --- | -| Empty pointer | With no journal present, SessionStart returns valid JSON with a create instruction naming `TEMPLATE.md` | -| Populated pointer | A synthetic journal yields its relative path, Status, and complete next step | -| DONE handling | A synthetic `**Status:** DONE` journal is reported as finished, not replayed as the live next step | -| Fresh Stop | Fresh journal plus `{"stop_hook_active":false}` exits 0 | -| Stale Stop | Old THIN and DETAILED fixtures exit 2 with mode-correct guidance | -| Loop protection | `{"stop_hook_active":true}` skips freshness and exits 0 | -| Secrets guard | Construct a synthetic credential assignment at runtime; Stop exits 2 without echoing the value | -| Redaction accepted | Replacing that value with `` exits 0, so the prescribed fix actually clears the block | - -Delete verification fixtures. Then write the first real journal about this adoption session, with no secrets, and run the pointer once more so the install demonstrates itself. - -For Codex, trust the project, review the changed hooks with `/hooks`, and verify behavior with the installed Codex version. For Claude Code, run a small headless task if available. If a runtime cannot be exercised, say so and retain the manual fallback wording; never claim an unobserved hook fired. +| Empty start | With no journal, SessionStart returns context that names `TEMPLATE.md` | +| Candidates | A synthetic ACTIVE journal is listed with its next step; a DONE one is not | +| Binding | A PostToolUse `Write` to a journal binds it: `whoami ` shows it | +| IN USE | A PreToolUse `Write` from a second session id into that journal is denied | +| Read-only turn | Stop with no edits in the transcript prints `{}` | +| Landed turn | Stop after an edit and a commit, with no journal write, returns `decision: block` once; a second Stop in the same turn does not | +| Credential | A PreToolUse `Write` whose content holds a credential assignment built at runtime is denied, and the reason does not contain the value | +| Redaction | The same write with `` is allowed | + +Delete the fixtures and the throwaway state. Then write the first real journal, about this adoption session, with +no secrets. + +For Codex, trust the project, review the hooks with `/hooks`, and run one short task with the installed Codex +version. For Claude Code, run a short headless task if available. If a runtime cannot be exercised, say so. Never +claim that an unobserved hook fired. ## Phase D — hand it to the team -An installation that exists only on one machine is not adopted. Unless the user declined committing: - -1. Commit `.claude/` (or `.codex/`), the instruction-file blocks, and the journal directory together, so a teammate's first clone arrives with the discipline already wired. -2. Confirm no ignore rule excludes them. `./doctor.sh` fails explicitly when the hook settings are gitignored or uncommitted. -3. Tell the user what a teammate must do on first clone — normally nothing, which is the point — and that `./doctor.sh` is how anyone checks later, since a hook that stops firing is otherwise silent. -4. Mention `SESSION_JOURNAL_SKIP=1` for sessions that genuinely need no journal. Without it, the team learns to route around the Stop hook, and the discipline dies quietly. +1. Commit `.claude/` (and `.codex/` and `.agents/` when Codex is wired), the instruction blocks, and the journal + directory together. `.claude/state/` ignores itself. +2. Confirm that no ignore rule excludes them. The doctor checks this. +3. Tell the user what a teammate must do on first clone (normally nothing). Tell them that `./doctor.sh` is how + anyone checks the setup later. +4. Mention `python3 .claude/hooks/session-journal.py skip` and `SESSION_JOURNAL_SKIP=1`. Mention + `JOURNAL_ROLE=worker` for dispatched agents. ## Phase E — document reversal -Uninstalling removes the two owned hook groups, the two installed scripts, and the sentinel-delimited instruction blocks. It keeps the journal directory, its README, and TEMPLATE.md by default because they are project history. The deterministic installer performs this with `./install.sh --uninstall` using the same runtime flags used for installation. +`./install.sh --uninstall`, with the runtime flags you installed with, removes: + +- the owned hook groups (v1 and v2), the script, and the skills; +- local state; +- the instruction blocks. -Upgrading is the same command as installing: re-run `./install.sh ` from a fresh clone. The re-run preserves the adapted README, TEMPLATE, instruction blocks, and environment overrides, and reports each thing it kept. +It keeps the journal directory, its README, and TEMPLATE.md, because they are project history. Upgrading is the +same command as installing, run from a fresh clone. ## Final report -Report the survey decisions, changed files, the `./doctor.sh` output, the behavioral matrix, any runtime limitation, first-journal path, whether the install was committed, and the uninstall command. Mention any question that used the three-question budget. +Report: + +- the survey decisions; +- the changed files; +- the `./doctor.sh` output and the behavioural matrix; +- any runtime limitation; +- the role configuration; +- the first journal's path; +- whether the install was committed; +- the uninstall command; +- any question that used the three-question budget. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae85db..fce8f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,84 @@ All notable changes to this project are recorded here. Versions follow [semantic versioning](https://semver.org/): the public surface is the journal shape, the hook contract, the installer flags, and the environment variables. +## 2.0.0 + +Session-bound journals. 1.x asked "which journal is newest?" at every session start and every turn end. With more +than one agent session in a repository (parallel chats, worktrees, a lead dispatching workers), the newest journal +is usually someone else's. 2.0 asks "which journal is *this* session's?" instead. + +The changes follow a field audit of a long-running private deployment of this discipline: about a thousand +sessions, and several hundred journals. Its generic findings, in the order they drove the design: + +- **The start pointer named the wrong journal.** In the busiest period, the newest-journal pointer was irrelevant to + the prompt in roughly half of all sessions, and agents opened it less every month. +- **Parallel sessions polluted each other.** A fresher journal from another session silenced this session's nudge, + or redirected it. About one in eight reminders named another session's journal. Agents appended unrelated work + into journals that other live sessions were writing. +- **Size followed writer count.** Journals with one writer stayed small. Journals with many writers grew past + tens of kilobytes and mixed workstreams. +- **Nudges arrived where they did not belong.** They fired on read-only turns, looped on long coordinator sessions, + and reached dispatched workers that correctly had no journal. A leaked loop marker silenced the turn after every + nudge. +- **Linked worktrees** wrote journals into their own stale copy of the journal directory. +- **Shell and Python journal writes** were invisible to a check based on editing tools. +- **A background job** that touched a journal took over the pointer. + +### Behaviour changes (why this is a major version) + +- **One Python 3 script replaces both shell hooks.** It handles six events (`SessionStart`, `UserPromptSubmit`, + `PreToolUse`, `PostToolUse`, `Stop`, `SessionEnd`) and a CLI. It uses the standard library only and needs + Python 3.8 or later. `jq` is still needed by the installer only. +- **SessionStart no longer injects the newest journal.** It names the journal this session is bound to. With no + binding, it lists up to five ranked open candidates, with IN USE marked. On the first prompt it adds up to two + journals whose name or title matches. +- **Stop checks this session's own journal, from this turn's facts.** It no longer compares file modification + times. It nudges at most once per turn, and only when the turn landed work. Freshness windows are gone. +- **Writes into a journal another live session owns are denied.** So are writes into a linked worktree's copy of + the journal directory. +- **The credential check moved from Stop to PreToolUse.** The write is denied before it reaches disk, and no Stop + loop is needed. The rules are retuned for compound key names (`dbPassword=`, `SERVICE_API_KEY:`), prose, table + cells, URL credentials, and CLI flags. The bare-hexadecimal rule is gone. +- **Sessions end by marking an ACTIVE journal PAUSED**, and record themselves on a `**Sessions:**` line. `PAUSED` + joins the status vocabulary. `JOURNAL_PAUSE_ON_END=0` turns this off. +- **`SESSION_JOURNAL_DIR` is relative to the main checkout.** The installer rewrites the v1 absolute form. + +### Removed + +- `hooks/session-journal-pointer.sh` and `hooks/session-journal-nudge.sh`. The installer removes them, and their hook + groups, from adopted repositories. +- `JOURNAL_FRESH_SECS`, `JOURNAL_REQUIRE_CONTENT_CHANGE`, and `JOURNAL_STOP_MARKER_TTL_SECS`. A re-install drops + them from the generated commands. + +### Added + +- Session leases in `.claude/state/session-journal/`, which ignores itself. +- Main-checkout resolution for linked worktrees. A pooled worktree whose directory is named after the repository is + labelled by its branch. +- `/clear` handling: the window's previous journal is offered first. A fork never inherits its parent's journal. +- Roles. `JOURNAL_ROLE=worker` sessions are never nudged and their journal writes are denied. `JOURNAL_WORKER_PROMPT` + makes soft workers from a configurable first-prompt pattern. A worker ledger shows landed worker results to the + lead once. +- A size limit (`JOURNAL_MAX_KB`, default 40) that tells the agent to continue in a new journal with + `**Continues:**`. +- The CLI: `candidates`, `find`, `reconstruct`, `list`, `health`, `scan`, `sweep [--by-git]`, `whoami`, `bound`, + `bind`, `release`, `role`, `skip`, and `version`. +- Codex support for every event. `apply_patch` edits are read from `tool_input.command`, Codex turns are keyed by + `turn_id`, and a Stop hook always prints JSON, as Codex requires. +- The `session-journal` agent skill (`skills/session-journal/`), with a Codex mirror that carries + `agents/openai.yaml` (`codex/skills/session-journal/`). The installer places them in `.claude/skills/` and + `.agents/skills/`. `ADOPT.md` now routes the adopting agent through the skill. +- `--no-skill`. The doctor now checks all six events, the `|| true` safety net on each command, leftover v1 hooks, + Python, and that local state stays out of git. It also runs the real hooks against a scratch state directory. +- `tests/test_journal.py`: scenario tests for parallel sessions, IN USE, worktrees, `/clear`, forks, workers, + debouncing, THIN nudges, credentials, Codex-shaped input, sweep, and a custom journal directory. + +### Upgrade notes + +Re-run `./install.sh ` from a fresh clone, with the flags you installed with, then run `./doctor.sh ` +and commit. Journals are not renamed or rewritten. The adapted README, TEMPLATE, and instruction blocks are kept. +Pass `--refresh-docs` to pick up the 2.0 wording for them, which describes candidates and IN USE. + ## 1.1.0 The first release that treats the discipline as something people *operate* diff --git a/CLAUDE.md b/CLAUDE.md index cf318f0..892ed00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,9 @@ ## Session-journal discipline -For substantive work, keep one journal in `sessions/` using `YYYY-MM-DD-HHMM-.md`. Read the newest journal before resuming work. Continue the same task in the same file; create a new file for unrelated work. +For substantive work, keep one journal in `sessions/` using `YYYY-MM-DD-HHMM-.md`, started from `sessions/TEMPLATE.md`. The SessionStart hook names the journal this session is bound to, or lists candidates: continue the one the task really continues, never one marked IN USE (start your own with `**Continues:**` instead), and never pick a journal by newest name or date. Search older work with `python3 .claude/hooks/session-journal.py find `. -Use THIN mode when `## Live plan pointer` names a governing artifact, and DETAILED mode when it is `none`. Before ending a resumable turn, update `**Last updated:**`, `**Status:**`, evidence, current state, and `## Next step for a fresh agent`. Never put secrets or credential fragments in a journal. Follow `sessions/README.md`. +Journal only when something lands: a commit, a meaningful edit, a dispatch, a verified result, or a decision. Use THIN mode when `## Live plan pointer` names a governing artifact, and DETAILED mode when it is `none`. When you update, refresh `**Last updated:**`, `**Status:**`, evidence, current state, and `## Next step for a fresh agent`. Never put secrets or credential fragments in a journal. Workers started with `JOURNAL_ROLE=worker` report to their lead and write no journal. Follow `sessions/README.md` and the `session-journal` skill. If both CLAUDE.md and AGENTS.md carry this block, keep their session-journal rules synchronized. diff --git a/README.md b/README.md index 63c6bb0..a79c3ab 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,31 @@ # Session Journal -Give coding agents a memory between sessions: one small journal, a start hook that points to it, and a stop hook that keeps it current. +Give coding agents a memory between sessions. Each piece of work gets one small journal, and each journal is bound +to the session that writes it. Parallel sessions, worktrees, and lead/worker setups therefore stop reading, and +writing into, each other's journals. -This pattern was hardened over months in private enterprise-delivery repositories, then rewritten as a clean, standalone project. It supports Claude Code and Codex with the same portable shell hooks, and it remains useful as an instruction-only discipline in other agent runtimes. +It supports Claude Code and Codex with one dependency-free Python 3 script. In other agent runtimes it works as an +instruction-only discipline. ## What you get -- A fixed journal shape optimized for a fresh agent's first minute. -- THIN mode for work governed by a plan, and DETAILED mode for one-off work. -- `SessionStart` context containing the live journal, its status, and complete next step. -- A one-shot `Stop` nudge for missing or stale journals. -- A secrets guard that blocks common credential shapes without printing values. -- A re-runnable `doctor.sh` self-check, because a hook that stops firing is silent. -- An agent-led adoption contract that surveys and adapts to the target repository. -- A deterministic, idempotent installer and uninstall path. +- **A fixed journal shape**, built for a fresh agent's first minute. THIN mode is for work a plan governs; DETAILED + mode is for one-off work. +- **Session binding.** A journal belongs to the session that first writes it. At start, a session is told the + journal it is bound to. With no binding, it gets ranked candidates instead of "the newest file". +- **IN USE protection.** Writes into a journal that another live session owns are denied. The agent is told to + start its own journal with a `**Continues:**` link. +- **Quiet nudges.** Stop asks for an update only when the turn landed work (an edit or a commit) and did not touch + this session's journal, and at most once per turn. Read-only turns are never nudged. +- **Worktrees, `/clear`, and forks.** Journals stay in the main checkout. `/clear` offers the window's previous + journal. A fork never inherits its parent's journal. +- **Lead and worker roles.** Workers write no journals. Their landed results reach the lead. +- **A credential check.** A journal write that looks like it carries a credential is denied, and the denial never + shows the value. +- **A CLI** for continuity work: `candidates`, `find`, `reconstruct`, `list`, `health`, `scan`, `sweep`. +- **An agent skill** for the judgment calls the scripts cannot make: adopting, adapting, roles, migration, recall. +- **A doctor and an installer.** `doctor.sh` re-checks an installation at any time. The installer is idempotent, + migrates v1 in place, and uninstalls cleanly. ## 60-second agent install @@ -21,7 +33,9 @@ In your repository, tell your agent: > Read https://github.com/dctmfoo/session-journal/blob/main/ADOPT.md and install the session-journal discipline in this repo, adapted to how this repo works. Ask me only the questions ADOPT.md tells you to ask. -The contract makes the agent inspect your runtimes, instruction files, hooks, docs topology, timezone, ignore rules, and monorepo shape before it writes anything. It must merge rather than clobber and report a behavioral verification matrix. +`ADOPT.md` sends the agent through the `session-journal` skill (`skills/session-journal/SKILL.md`). The agent +surveys the repository's runtimes, instruction files, documentation, worktree habits, and ignore rules, runs the +installer, adapts the generated docs, and reports what the doctor printed. For offline use: @@ -29,7 +43,8 @@ For offline use: npx degit dctmfoo/session-journal session-journal-source ``` -Then point your agent at the local `ADOPT.md`. Pin a release tag rather than tracking `main` if you want a reproducible install. +Then point your agent at the local `ADOPT.md`. Pin a release tag rather than tracking `main` if you want a +reproducible install. ## Deterministic install @@ -42,40 +57,76 @@ Run from a clone; the installer does not fetch code: | Option | Effect | | --- | --- | | `--sessions-dir ` | Journal directory, relative to the target repo. Default `sessions`. | -| `--plan-pointer ` | Default `## Live plan pointer` for the generated docs. Default `none` (DETAILED). Give it a path to start in THIN mode. | -| `--codex` | Also install Codex hooks and the `AGENTS.md` block. | -| `--no-claude` | Skip the Claude Code hooks and the `CLAUDE.md` block. | +| `--plan-pointer ` | Default `## Live plan pointer` for the generated docs. Default `none` (DETAILED). | +| `--codex` | Also install Codex hooks, the Codex skill (`.agents/skills/`), and the `AGENTS.md` block. | +| `--no-claude` | Skip the Claude Code hooks, the Claude skill, and the `CLAUDE.md` block. | +| `--no-skill` | Do not install the agent skill. | | `--refresh-docs` | Regenerate the sessions README, `TEMPLATE.md`, and instruction blocks, discarding local adaptations. | -| `--uninstall` | Remove the owned hooks, scripts, and instruction blocks. | +| `--uninstall` | Remove the owned hooks, script, skills, local state, and instruction blocks. | -`jq` is required because settings are merged structurally. Invalid JSON or JSON5 is refused rather than rewritten. Every refusal happens before the first byte is written, so a refused install never leaves hooks half-wired. +The installer needs `jq` because it merges settings structurally. It refuses invalid JSON or JSON5 rather than +rewriting it. Every refusal happens before the first byte is written. Agents need `python3` 3.8 or later. Every hook +command ends in `|| true`, so a missing interpreter disables the hooks instead of blocking the agent. -Then commit the result. `.claude/` and the journal directory are what carry the discipline to your teammates; an install that only exists on your machine is not adopted. +What it writes: -Codex project hooks load only for trusted projects. After installation, open `/hooks` in Codex and review the new definitions; changed definitions require review again. Codex CLI 0.144.1 was verified with both `SessionStart` and turn-scoped `Stop`, so the same pointer, freshness nudge, and secrets guard run in Codex. The AGENTS.md block keeps a manual fallback for older or disabled hook surfaces. +- `.claude/hooks/session-journal.py`: the single hook script. +- Six hook groups per runtime: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, and + `SessionEnd`. +- `/README.md` and `/TEMPLATE.md`. +- The skill. +- A sentinel-delimited block in `CLAUDE.md` and/or `AGENTS.md`. -## Check the installation +Leases and traces live in `.claude/state/session-journal/`, which ignores itself. + +Then commit the result. `.claude/`, `.codex/`, `.agents/`, and the journal directory carry the discipline to your +teammates. An install that exists only on your machine is not adopted. + +Codex loads project hooks only for trusted projects. After installation, open `/hooks` in Codex and review the new +definitions. + +## Upgrading from 1.x ```bash +./install.sh /path/to/your-repo # same flags you installed with ./doctor.sh /path/to/your-repo ``` -Every failure mode in this project is silent by construction: a `SessionStart` hook that never fires looks exactly like a session with no journal, and a `Stop` hook that never fires looks exactly like a journal that is always fresh. The doctor re-runs the adoption verification matrix at any time, and checks what adoption cannot: whether the installation is committed, whether a teammate will actually receive it, whether the registered command still resolves, and which journal the pointer would deliver right now. It exits non-zero if anything fails, so it works in CI. +The installer does the following: -## Updating +- Removes the v1 `session-journal-pointer.sh` and `session-journal-nudge.sh` scripts and their hook groups, and + wires the new script. +- Keeps your journal directory, rewriting it as a relative `SESSION_JOURNAL_DIR` so linked worktrees resolve it to + the main checkout. +- Keeps your adapted README, TEMPLATE, and instruction blocks. +- Drops the v1-only freshness variables. -Re-run the installer from a fresh clone: +No journal is renamed or rewritten. See [CHANGELOG.md](CHANGELOG.md) for what behaves differently. + +## Check the installation ```bash -./install.sh /path/to/your-repo +./doctor.sh /path/to/your-repo ``` -The rerun preserves your adapted `sessions/README.md`, your `TEMPLATE.md`, your instruction block, and any environment overrides on the generated commands, printing a line for each thing it keeps. Pass `--refresh-docs` when you want the upstream wording back. Check [CHANGELOG.md](CHANGELOG.md) for upgrade notes, then run `./doctor.sh`. +Every failure mode here is silent by construction. A hook that never fires looks exactly like a quiet session. The +doctor checks that: + +- the wiring is complete, and every command has its `|| true` safety net; +- no v1 hooks remain; +- Python is available and the installed script runs; +- the installation is committed and not ignored; +- local state stays out of git. + +It then runs the real `SessionStart` and `Stop` hooks against a scratch state directory, scans every journal for +credential shapes, and reports hook errors logged on this machine. It exits non-zero on any failure, so it works in +CI. ## The journal in one glance ```markdown **Status:** ACTIVE — installer tests are green +**Continues:** sessions/2026-03-02-0915-release-prep.md ## Live plan pointer docs/plans/release.md @@ -84,33 +135,72 @@ docs/plans/release.md Run the packaging check, then record the artifact hash here. ``` -The pointer makes this THIN mode: details stay in the release plan. With `none`, the journal becomes DETAILED and carries the working record itself. See [the full specification](spec/session-journal-spec.md) and [template](templates/session-journal.md). +The pointer makes this THIN mode: the details stay in the release plan. With `none`, the journal is DETAILED and +carries the working record itself. See [the specification](spec/session-journal-spec.md) and +[the template](templates/session-journal.md). + +## The CLI + +```bash +python3 .claude/hooks/session-journal.py candidates [words] # bound journal, then open candidates (IN USE marked) +python3 .claude/hooks/session-journal.py find # search journals and the worker ledger +python3 .claude/hooks/session-journal.py reconstruct # next step, sessions that wrote it, Continues chain +python3 .claude/hooks/session-journal.py list [--all] # sessions and their journals +python3 .claude/hooks/session-journal.py health # drift indicators (JSON) +python3 .claude/hooks/session-journal.py scan [files] # credential shapes; line numbers only +python3 .claude/hooks/session-journal.py sweep [hours] [--by-git] [--dry-run] # pause idle ACTIVE journals +python3 .claude/hooks/session-journal.py role lead|worker | skip | release | bind +``` -Set `**Status:** DONE` when the work is finished. A DONE journal is background, not the live frontier — the next session starts clean instead of inheriting a completed next step. +The session id defaults to `$CLAUDE_CODE_SESSION_ID` (Claude Code) or `$CODEX_THREAD_ID` (Codex); set `SESSION_JOURNAL_SESSION_ID` to override it. ## Configuration -Every setting is an environment variable on the generated hook command, so it lives in your settings file and travels with the repository. +Every setting is an environment variable. To make one apply to everyone, put it at the front of the generated hook +commands, where it travels with the repository; a re-run of the installer keeps it. To scope one to a single +session, set it in that agent's environment. | Variable | Default | Effect | | --- | --- | --- | -| `SESSION_JOURNAL_DIR` | `/sessions` | Journal directory. Set by `--sessions-dir`. | -| `JOURNAL_FRESH_SECS` | `300` | How old the newest journal may be before `Stop` nudges. | -| `SESSION_JOURNAL_SKIP` | unset | Set to `1` for a session that genuinely needs no journal — a typo fix, a dependency bump. The secrets guard still runs. | -| `JOURNAL_REQUIRE_CONTENT_CHANGE` | `1` | Require the journal's content to have changed when tracked files did, not just its timestamp. Set to `0` for mtime-only freshness. | -| `JOURNAL_STOP_MARKER_TTL_SECS` | `60` | How long the one-shot loop marker stays valid, for runners that supply no stdin. | +| `SESSION_JOURNAL_DIR` | `sessions` | Journal directory, relative to the main checkout. Set by `--sessions-dir`. | +| `SESSION_JOURNAL_STATE_DIR` | `.claude/state/session-journal` | Leases, ledger, and trace. | +| `SESSION_JOURNAL_SKIP` | unset | `1`: never nudge this session (a typo fix, a dependency bump). The credential check still runs. | +| `SESSION_JOURNAL_DISABLE` | unset | `1`: every hook does nothing. | +| `JOURNAL_ROLE` | `lead` | `worker`: never nudged; journal writes denied. | +| `JOURNAL_WORKER_PROMPT` | unset | Regex. A session whose first prompt matches is a soft worker: never nudged, writes allowed. | +| `JOURNAL_LANDING_BASH` | unset | Regex. Extra shell commands that count as landed work. `git commit` and `git push` always count. | +| `JOURNAL_SECRETS` | `deny` | `warn` turns the credential check into advice; `off` disables it. | +| `JOURNAL_LIVE_MIN` | `45` | Minutes a silent session still holds its journal. A running agent process holds it longer. | +| `JOURNAL_LIVE_MAX_H` | `24` | Cap on the process-alive hold. | +| `JOURNAL_NUDGE_MIN` | `20` | Minutes after which a landed turn is nudged even without a commit. | +| `JOURNAL_MAX_KB` | `40` | Size above which an agent is told to continue in a new journal. | +| `JOURNAL_PAUSE_ON_END` | `1` | `0`: a session ending does not mark its ACTIVE journal PAUSED. | +| `JOURNAL_TRANSCRIPTS_DIR` | `~/.claude/projects` | Where `reconstruct` looks for transcripts. | ## Safety model -Journals are committed by default, so secret handling is deliberately strict. The Stop hook scans only the newest journal for JWT-like values, bearer values, credential assignments, provider-token shapes, private-key headers, and long hexadecimal strings. It reports line numbers, never matched values. +Journals are committed by default, so credential handling is strict. `PreToolUse` scans the new text of every +journal write, whether it comes from an edit, `apply_patch`, or a shell write. It looks for: -Self-identifying shapes are always flagged. For a `key: value` assignment the *value* is inspected, not just the key name, so the redactions this guard asks for — ``, `$STRIPE_API_KEY`, `redacted (32 chars)`, `stored-in-ci-secrets` — are accepted rather than blocking the agent in a redact-and-reblock loop. Quotes, backticks and markdown emphasis are stripped before that check, so `PASSWORD="…"` and a backticked value are still caught, and every credential key on a line is examined rather than only the first. A bare hexadecimal string is flagged only away from digest context: if the line, the line above, or the line below mentions a digest, hash, checksum, fingerprint, commit, revision, blob, oid, or etag *as a whole word*, the value is treated as verifiable evidence and left alone. A remaining false positive can carry ``, and that escape applies only to its own line. +- provider token shapes, JWTs, bearer values, and private-key headers; +- storage keys and signed URLs; +- credentials embedded in URLs; +- `key: value` assignments whose key names a secret; +- passwords stated in prose or in table cells. -This is a continuity aid, not a credential scanner for the whole repository. Keep your normal secret-scanning and review gates. See [SECURITY.md](SECURITY.md). +For named keys the *value* is inspected. The redactions the check asks for therefore pass: ``, +`$API_TOKEN`, `stored-in-ci-secrets`, `redacted (32 chars)`. So do identifiers and references. The denial names +lines and rules, never values. `` exempts a line a human has verified. + +This is a continuity aid, not a repository credential scanner. Keep your normal secret-scanning gates. See +[SECURITY.md](SECURITY.md). ## Optional spine promotion -Long-running DETAILED journals often mean a durable plan is missing. The optional module under `extras/spine-promotion/` nudges after a configurable milestone count, then asks the agent to promote recurring work into a plan or specification and switch back to THIN. It is an intentionally manual add-on: the core installer and uninstaller do not manage its Stop entry. +Long-running DETAILED journals often mean a durable plan is missing. The optional module under +`extras/spine-promotion/` nudges after a configurable number of milestones and asks the agent to promote recurring +work into a plan. It is managed by hand: the installer does not wire it. It still judges the newest journal by +modification time. ## Uninstall @@ -118,37 +208,49 @@ Long-running DETAILED journals often mean a durable plan is missing. The optiona ./install.sh /path/to/your-repo --codex --uninstall ``` -The installer removes only its hook groups, scripts, and sentinel-delimited instruction blocks. It keeps journals, their README, and `TEMPLATE.md` because they are project history. +This removes the owned hook groups (v1 and v2), the script, the skills, local state, and the instruction blocks. It +keeps journals, their README, and `TEMPLATE.md`, because they are project history. ## Troubleshooting -Indexed by what you actually observe. +**The agent says nothing about journals at start.** `SessionStart` is not firing. Run `./doctor.sh`. Likely causes: -**The agent starts a session and says nothing about a journal.** The `SessionStart` hook is not firing. Run `./doctor.sh` — the likely causes are an uncommitted or gitignored `.claude/`, an install into a subdirectory rather than the repository root, or a hook script that lost its executable bit. +- an uncommitted or ignored `.claude/`; +- no `python3` on the agent's `PATH`; +- a missing journal directory (the hooks never create it). -**The agent was handed an old journal.** Fixed in 1.1.0: selection used to be decided by mtime alone, and a fresh clone gives every journal the same timestamp, which kept the alphabetically oldest one. Upgrade, then confirm with `./doctor.sh`, which reports which journal the pointer would deliver. +**The agent appended to someone else's journal.** It wrote before the other session bound it, or the other session +had been silent for more than `JOURNAL_LIVE_MIN` with no running process. Check with +`python3 .claude/hooks/session-journal.py list --all`. -**A fresh agent is told there is no work left.** The newest journal is `DONE`. Since 1.1.0 the pointer prefers the newest `ACTIVE` or `BLOCKED` journal and, when every journal is closed, says so rather than replaying a finished next step. +**A write was denied as IN USE, but that session is gone.** Run +`python3 .claude/hooks/session-journal.py release `. -**`Stop` keeps blocking no matter what the agent writes.** The secrets guard is matching something. It runs ahead of the loop protection by design, so `stop_hook_active` will not clear it. The message names the line numbers; redact the value to a key name, an env-var reference, or ``. If the line is a verified false positive, append `` to it. +**A write was denied for a credential that is not one.** Redact the value to a reference. If a human confirms the +line is clean, append ``. -**`Stop` nudges even though the journal was just updated.** Since 1.1.0 the gate requires content to change, not just the timestamp, when *tracked* files changed. Untracked scratch files are ignored, and a journal committed alongside its work counts as recorded. If it still misfires for your workflow, set `JOURNAL_REQUIRE_CONTENT_CHANGE=0`. +**Workers keep getting nudged.** Start them with `JOURNAL_ROLE=worker`, or set `JOURNAL_WORKER_PROMPT` to match your +orchestrator's dispatch prompt. -**Every trivial session demands a journal.** Set `SESSION_JOURNAL_SKIP=1` for that session. +**A trivial session demands a journal.** Run `python3 .claude/hooks/session-journal.py skip`, or start the agent +with `SESSION_JOURNAL_SKIP=1`. -**A teammate cloned the repo and none of this works for them.** `.claude/` is probably gitignored or was never committed. `./doctor.sh` reports this explicitly. +**A teammate cloned the repo and none of this works for them.** `.claude/` is probably ignored or uncommitted. +`./doctor.sh` reports it. ## FAQ -**Why not use conversation history?** A journal is runtime-neutral, reviewable in git, and written around current state and next action rather than transcript chronology. - -**One journal per agent?** No. One per substantive human working session. A monorepo usually keeps one journal directory at its workspace root. +**Why not use conversation history?** A journal is runtime-neutral, reviewable in git, and written around current +state and next action rather than transcript order. -**Does Stop block forever?** No. Hook JSON uses `stop_hook_active`; empty-stdin runners get a short-lived marker fallback. The secrets guard still runs after the freshness nudge has been spent, and it is the one thing that can block repeatedly — deliberately, because the alternative is committing a credential. +**One journal per agent?** One per piece of work, owned by one live session at a time. Workers report to a lead, +whose journal records the outcome. -**Can journals be local-only?** Yes. The default is committed continuity, but ADOPT.md explicitly asks when ignore intent is ambiguous. `./doctor.sh` will tell you which you have. +**Why is there a state directory now?** Binding needs to know which session wrote what. The state is small JSON, +local to the machine, and ignores itself. Losing it costs live IN USE locks, never journal content. -**Why is there no database?** Because the journal is the compression. It is written by an agent, read by a human, reviewed in a pull request, and carried by git. A store would add a runtime, a migration story, and a corruption story to a project whose entire value is that it has none of those. +**Why Python instead of shell?** Six events, transcript parsing, locking, and a CLI did not fit in portable shell. +The script uses the standard library only, and runs with Python 3.8 or later. ## License diff --git a/SECURITY.md b/SECURITY.md index 5d7dda7..d144662 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,43 +2,49 @@ ## What this project does to your repository -`install.sh` copies two executable shell scripts into `.claude/hooks/` and wires -them into `SessionStart` and `Stop` in your agent settings. From then on, your -agent runtime executes them automatically, on every session start and every -turn end, with your permissions. Read both scripts before you install them — -they are short and deliberately dependency-free. +`install.sh` copies one Python script, `.claude/hooks/session-journal.py`, into your repository. It wires that +script into six hook events in your agent settings: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, +`PostToolUse`, `Stop`, and `SessionEnd`. From then on, your agent runtime runs it automatically, with your +permissions. Read it before you install it: it uses the Python standard library only, and it runs no network code. -The installer never fetches code. It only copies from the clone you run it -from, which is why the README tells you to clone or `degit` first. +What the script writes at runtime: -## What the secrets guard is, and is not +- **Leases, a worker ledger, and a trace** in `.claude/state/session-journal/`. This directory ignores itself + (`.gitignore` containing `*`). It records session ids, worktree and branch names, transcript paths, the first + 200 characters of a worker's first prompt, and a worker's last reply (up to 400 characters). It never leaves the + machine unless you copy it. +- **Two header lines in journals**, when a session ends with no other live session owning the journal: it appends + the session to `**Sessions:**`, and turns `ACTIVE` into `PAUSED` (set `JOURNAL_PAUSE_ON_END=0` to keep the + status). `sweep` changes `ACTIVE` to `PAUSED` only when you run it. -The `Stop` hook scans **only the newest journal** for common credential shapes: -JWT-like values, bearer values, credential assignments, provider token prefixes, -private-key headers, and long hexadecimal strings outside digest context. It -reports line numbers and never echoes a matched value, so the hook itself cannot -leak a secret into a transcript. +The installer never fetches code. It only copies from the clone you run it from. + +## What the credential check is, and is not + +`PreToolUse` scans the **new text** of each journal write: an editing tool, `apply_patch`, or a recognisable shell +write. It looks for credential shapes and denies the write when one matches. The denial names line numbers and rule +names, never the matched value, so the hook cannot leak a secret into a transcript. `scan` and `./doctor.sh` apply +the same rules to every journal on disk. It is a continuity aid, not a credential scanner: -- It scans one file, not your repository. -- It is a shape matcher. It will miss a secret that does not look like one — a - passphrase in prose, a credential split across lines, an encoded blob. -- For `key: value` assignments it inspects the value, and deliberately exempts - two classes so that redacting does not re-trigger the block: values shorter - than 8 characters, and values whose first word says where the secret lives - rather than what it is (`redacted`, `stored-…`, `see-…`, `$ENV_VAR`, - ``). A real credential that happens to start with one of those words - will be missed. Surrounding quotes, backticks and markdown emphasis are - stripped before the check, so decoration does not hide a value. -- It fails open on anything it cannot parse, because a Stop hook that blocks - forever is worse than one that misses. -- The `` escape suppresses a line entirely. It exists - for verified false positives and should be added by a human, not an agent. +- It scans journal writes, not your repository. +- It matches shapes. It will miss a secret that does not look like one: a passphrase in plain prose, a credential + split across lines, or an encoded blob. +- For named keys it inspects the value. To keep redactions from re-triggering the check, it exempts values shorter + than 6 characters, identifiers (`app-secret`, `stored-in-ci-secrets`, `ENV_VAR_NAME`, dotted property names), + paths and URLs, plain words, and placeholders (`<...>`, `$VAR`, `${...}`, `***`, `REDACTED`). A real credential + that looks like one of those will be missed. +- A shell write the hook cannot recognise as a journal write is not scanned before it runs. `scan` finds the result + afterwards. +- Unlike 1.x, it no longer flags bare 40+ character hexadecimal strings. Commit hashes and digests are evidence in + journals, and that rule produced false positives. +- `JOURNAL_SECRETS=warn` turns denial into advice. `off` disables the check. +- The `` escape exempts one line. It exists for verified false positives, and a human + should add it, not an agent. Keep your normal secret scanning and review gates. This project runs -[gitleaks](https://github.com/gitleaks/gitleaks) over its own full history in -CI and recommends you do the same. +[gitleaks](https://github.com/gitleaks/gitleaks) over its own full history in CI, and recommends you do the same. ## Journals are committed by default @@ -51,16 +57,19 @@ tracked or local-only. ## Reporting a vulnerability Open a [security advisory](https://github.com/dctmfoo/session-journal/security/advisories/new) -rather than a public issue. Please include the version from your installed hook -header (`session-journal `), your agent runtime and version, and a -reproduction. +rather than a public issue. Please include the output of +`python3 .claude/hooks/session-journal.py version`, your agent runtime and +version, and a reproduction. Report privately if you find: -- a way to make either hook execute attacker-controlled content from a journal, -- a path in the installer that writes outside the target repository, -- a way to make the secrets guard echo a matched value, -- a way for a crafted journal to hang or infinitely block a Stop hook. +- a way to make the hook execute attacker-controlled content from a journal, a + transcript, or hook input; +- a path in the installer or the hook that writes outside the target repository + or its state directory; +- a way to make the credential check echo a matched value; +- a way for crafted input to hang the hook, or make it block a tool call or a + turn indefinitely. A missed secret shape is a bug, not a vulnerability — open a normal issue with a synthetic example. Never put a real credential in an issue. diff --git a/docs/verification.md b/docs/verification.md index 9090ce6..e8a0b85 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -11,20 +11,35 @@ section are checked by `tests/docs.bats`, so they cannot quietly rot. Run these to reproduce the claim rather than trusting a number frozen here: ```bash -bash -n hooks/*.sh install.sh doctor.sh extras/spine-promotion/*.sh -shellcheck hooks/*.sh install.sh doctor.sh extras/spine-promotion/*.sh +bash -n install.sh doctor.sh extras/spine-promotion/*.sh +shellcheck install.sh doctor.sh extras/spine-promotion/*.sh +python3 -m unittest discover -s tests -p 'test_*.py' bats tests ./doctor.sh . ``` | Gate | Claim | | --- | --- | -| bats-core | 123 checks, all passing | +| Python scenario tests | 53 scenario methods in `tests/test_journal.py` (the session-binding class runs twice, once under `docs/sessions`), all passing | +| bats-core | 66 checks, all passing | | Shellcheck and `bash -n` | clean for every shell entrypoint | | Self-check | `./doctor.sh .` passes against this repository's own installation | | Secrets | the runtime guard runs over every tracked journal in CI, plus a pinned full-history gitleaks scan | -## Adoption record — 2026-07-13, macOS +## 2.0.0 record — 2026-10-21, macOS + +A snapshot, never rewritten. Python 3.14 and Claude Code 2.1.306 / Codex CLI 0.163.0 were installed on the machine. + +- The scenario suite and the bats suite passed at the revision that introduced 2.0.0. Re-run the commands under + **Current gates** for today's counts. +- The installer ran twice into a fresh scratch repository under `/tmp` (with and without `--codex`), then into a + scratch v1 installation, which it migrated. `./doctor.sh` passed on each. +- Simulated sessions piped hook JSON into the installed script: start, edit, commit, Stop nudge, journal write, + Stop quiet, SessionEnd. +- Live Claude Code and Codex sessions against 2.0 were **not** exercised in this record; the hook input and output + shapes were checked against each runtime's published hook reference. + +## Adoption record — 2026-07-13, macOS (1.0, shell hooks) ## Automated suites diff --git a/examples/adopted-repo-tour.md b/examples/adopted-repo-tour.md index a73eee0..4505b1d 100644 --- a/examples/adopted-repo-tour.md +++ b/examples/adopted-repo-tour.md @@ -6,9 +6,12 @@ An agent-led installation might leave this small footprint: project/ ├── .claude/ │ ├── hooks/ -│ │ ├── session-journal-pointer.sh -│ │ └── session-journal-nudge.sh +│ │ └── session-journal.py +│ ├── skills/session-journal/SKILL.md +│ ├── state/session-journal/ (local only; ignores itself) │ └── settings.json +├── .agents/ +│ └── skills/session-journal/ (Codex mirror, with agents/openai.yaml) ├── .codex/ │ └── hooks.json ├── sessions/ @@ -18,6 +21,14 @@ project/ └── CLAUDE.md ``` -Both runtimes reference the same scripts. Existing hook groups remain beside the installed groups. The local `sessions/README.md` says which docs are normal THIN pointers, which timezone label to use, and which evidence identifiers matter in this repository. +Both runtimes run the same script. Existing hook groups remain beside the installed groups. The local `sessions/README.md` says which docs are normal THIN pointers, which timezone label to use, and which evidence identifiers matter in this repository. -On a fresh start, the pointer hook injects only the newest path, one-line status, and complete next step. On stop, a fresh journal exits cleanly; a stale journal gets one mode-aware continuation; secret-looking content remains blocking even after that continuation. +What happens when sessions run: + +- **New session.** The hook lists up to five open candidate journals, each with its status and next step. Journals + another live session owns are marked IN USE. +- **Resumed or compacted session.** The hook names the journal the session is bound to. +- **A turn that only read.** It ends quietly. +- **A turn that landed a commit without touching its own journal.** It gets one mode-aware request to update that + journal. Neither another session's journal nor a newer file affects this. +- **A credential-shaped journal write.** It is denied before it reaches disk. diff --git a/sessions/TEMPLATE.md b/sessions/TEMPLATE.md index 2b6f9cb..33e8f7b 100644 --- a/sessions/TEMPLATE.md +++ b/sessions/TEMPLATE.md @@ -2,7 +2,9 @@ **Started:** YYYY-MM-DD HH:MM IST **Last updated:** YYYY-MM-DD HH:MM IST -**Status:** ACTIVE | DONE | BLOCKED — one current-state line +**Status:** ACTIVE | PAUSED | BLOCKED | DONE — one current-state line +**Worktree:** () (only when written from a linked worktree; omit otherwise) +**Continues:** sessions/.md (omit when this is new work) ## User intent diff --git a/spec/session-journal-spec.md b/spec/session-journal-spec.md index 3eb4770..abba938 100644 --- a/spec/session-journal-spec.md +++ b/spec/session-journal-spec.md @@ -1,65 +1,148 @@ # Session Journal Specification -Version 1.1 +Version 2.0 ## Purpose -A session journal is a small, committed continuity record for coding agents and humans. It answers three questions after a fresh start: what work is active, what evidence exists, and what should happen next. +A session journal is a small, committed record for coding agents and humans. After a fresh start it answers three +questions: what work is active, what evidence exists, and what should happen next. ## Required shape -Each substantive session uses `sessions/YYYY-MM-DD-HHMM-.md`, or the repository's adapted journal directory. The filename time is the session start in the machine-local timezone. +Each piece of substantive work uses `sessions/YYYY-MM-DD-HHMM-.md`, or the repository's adapted journal +directory. The filename time is the session start in the machine-local timezone. Names without `HHMM` are still +recognised, so older journals keep working. The header contains bold labels, not YAML: ```markdown **Started:** YYYY-MM-DD HH:MM Zone **Last updated:** YYYY-MM-DD HH:MM Zone -**Status:** ACTIVE | DONE | BLOCKED — one current-state line +**Status:** ACTIVE | PAUSED | BLOCKED | DONE — one current-state line +**Worktree:** () optional; only when written from a linked worktree +**Continues:** sessions/.md optional; links work that picks up an earlier journal +**Sessions:** (/, ), ... maintained by the hooks; do not edit ``` -The body contains, in order: User intent; Live plan pointer; Milestones; Decisions and constraints; Verification evidence; Commits made; Files touched; Where we are now; Next step for a fresh agent; Open questions / loose ends; Change log. +The body contains these sections, in order: -`Next step for a fresh agent` is mandatory and concrete. Name the next command, file, test, decision, or owner action. It is injected verbatim at the start of the next session, so write it for a reader who has seen nothing else. Section headings are matched case-insensitively, but the template's capitalization is the canonical form. +1. User intent +2. Live plan pointer +3. Milestones +4. Decisions and constraints +5. Verification evidence +6. Commits made +7. Files touched +8. Where we are now +9. Next step for a fresh agent +10. Open questions / loose ends +11. Change log -Journal directories also carry two non-journal files, `README.md` and `TEMPLATE.md`. Neither is a journal and neither is ever selected as one. +`Next step for a fresh agent` is mandatory and concrete. Name the next command, file, test, decision, or owner +action. It is shown to the next session, so write it for a reader who has seen nothing else. Section headings match +case-insensitively; the template's capitalization is the canonical form. + +The journal directory also holds `README.md` and `TEMPLATE.md`. Neither is a journal. ## Modes The first non-empty line under `## Live plan pointer` selects the mode: -- A real artifact path means **THIN**. The journal indexes outcomes and evidence without retelling the artifact. -- `none`, an empty section, or a missing section means **DETAILED**. The journal is the canonical working record. +- **THIN:** the line names a real artifact. The journal indexes outcomes and evidence and does not retell the + artifact. +- **DETAILED:** the line is `none`, or the section is empty or missing. The journal is the canonical working record. + +Promote recurring DETAILED work into a durable plan, specification, ADR, runbook, or tracker. Later journals then +point to it in THIN mode. + +## Status + +- **Open:** any status except the closed ones. `ACTIVE`, `PAUSED`, `BLOCKED`, `WAITING` and `IN PROGRESS` are all + open. A journal without a status line is neither open nor a candidate. +- **Closed:** `DONE`, `COMPLETE`, `COMPLETED`, `DELIVERED`, `CLOSED`, `SUPERSEDED`, and `ABANDONED`. A closed journal + is background. It is never offered as a candidate. + +A session that ends while its journal is still `ACTIVE` sets it to `PAUSED`, unless no session was bound to the +journal or another live session owns it. Ending with `/clear` records the session but leaves the status alone, +because the same window usually carries on. + +## Binding: which journal is this session's -Recurring DETAILED work should be promoted into a durable plan, specification, ADR, runbook, or tracker; future journals then point to it in THIN mode. +A journal belongs to the sessions that write it. Picking "the newest journal" is not a selection rule; it is the +defect this version removes. With parallel sessions, the newest journal is usually someone else's. -## Lifecycle +1. Each agent session has a lease: its journal, worktree, branch, role, and liveness. The lease is local state and + is never committed. +2. The session's first write to a journal binds the lease to that journal. Writes count when they come through the + editing tools, through `apply_patch`, or through a recognisable shell write: a redirect, `tee`, `sed -i`, a + copy or move target, or a Python or Node write call. A shell command that changes a journal some other way is + detected by modification time afterwards. A command that changes more than two journals binds nothing. +3. Resume and compaction keep the session id, so the binding survives them. `/clear` and a fork start a new session: + - after `/clear`, the new session is offered the window's previous journal first; + - a fork is told that the journal in its history belongs to the original session. +4. A session with no binding is shown **candidates**: up to five open journals, ranked by + - a matching `Worktree:` (or `Sessions:`) line, + - uncommitted changes to the journal itself, + - an `ACTIVE` or `IN PROGRESS` status, + - the most recent write by an agent session; file modification time is used only when no agent wrote it. -Create a journal once intent is clear. Continue the same work in the same file even across days. Start unrelated work in a new file. After manual or automatic context compaction, reread the newest journal and governing artifact before taking action. + On the first prompt, up to two more journals whose name or title match the prompt are added. +5. A journal bound to another **live** session is **IN USE**. A session is live while its heartbeat is under 45 + minutes old, or while its agent process is still running (capped at 24 hours). Writes into an IN USE journal + are denied. The agent starts its own journal with `**Continues:**` instead. When two sessions are bound to the + same journal, the earlier binder owns it. +6. Journals live in the **main checkout**. A session in a linked worktree writes to the main checkout's directory + and adds a `**Worktree:**` line. A write into a linked worktree's copy of the directory is denied. +7. Hooks anchor on the session's project, never on the directory an agent has `cd`-ed into. A project without a + journal directory is left untouched: the hooks never create it. -Update `Last updated`, the one-line status, evidence, current state, next step, and change log before the end of every resumable turn. A Stop hook may treat a journal older than five minutes as stale; repositories can configure that window. +## When to update -Not every session earns a journal. A typo fix, a dependency bump, or a question answered without changing anything should be skipped explicitly rather than recorded as a journal about nothing. +Journal only when something lands: a commit, a meaningful edit, a dispatch, a verified result, or a decision. A turn +that only read or answered changes nothing in the journal. -## Selecting the live journal +The Stop hook nudges at most once per turn. It nudges only when the turn landed work and did not touch the +session's own journal, and one of these holds: -The live journal is the one a fresh agent must read first. Selecting it is a defined operation, not an implementation detail: +- a commit or push landed; +- the session has no journal yet; +- this is the second landed turn in a row without an update; +- 20 or more minutes have passed since the session's last journal update. -1. Consider every `*.md` in the journal directory except `README.md` and `TEMPLATE.md`. -2. Prefer the most recently modified. Modification time reflects the working session, which is why a journal appended to across days stays live even though its filename is older. -3. Break ties by filename, descending. Ties are common and not exotic: a fresh clone, a branch checkout, an unpacked archive, or a restored backup gives every journal the same timestamp, and the filename carries the session-start time precisely so it can settle them. -4. `DONE` marks a finished session, not the live frontier. If the journal selected above is `DONE`, prefer instead the most recent journal whose status is `ACTIVE` or `BLOCKED`. If none exists, report that the newest journal is closed rather than presenting its next step as live work. +A turn that only read and answered is never nudged. The THIN nudge is one line naming the plan pointer. The DETAILED +nudge asks for milestones, evidence, and the next step. + +When a journal grows past 40 KB, start a new journal with `**Continues:**` rather than growing it further. + +## Roles + +- **Lead** (default): owns journals. +- **Worker** (`JOURNAL_ROLE=worker`): writes no journal; its report to the lead is the record. Its journal writes + are denied. +- **Soft worker:** a session whose first prompt matches a configured dispatch pattern. It is never nudged, but may + keep a journal when its task asks for one. + +When a worker's turn lands work, the hooks add one line to a local ledger. The lead is shown each line once, on its +next prompt, and records the outcome in its own journal. ## Evidence -Record evidence a reader can re-run: the command, the test name, the CI run, the artifact reference. A count captured at write time — "38 of 38 passing" — becomes false as soon as the suite grows, and the next agent will believe it and repeat it. A journal is the highest-trust artifact a fresh agent reads, so a wrong line in it costs more than a wrong line elsewhere. +Record evidence a reader can re-run: the command, the test name, the CI run, the artifact reference. A count +captured at write time ("38 of 38 passing") becomes false as soon as the suite grows, and the next agent will +believe it and repeat it. ## Safety and signal -Include only concise outcomes, decisions, verification evidence, commit references, issue IDs, and artifact pointers. Never include secrets, credential fragments, private tokens, large logs, duplicated plan prose, or unverified claims. - -Redact a credential to a key name, an env-var reference, or an explicit placeholder. A redaction must not itself read as a credential; conforming redactions are expected to pass the guard. +Include only concise outcomes, decisions, verification evidence, commit references, issue IDs, and artifact +pointers. Never include: -The live journal is the hook contract. `SessionStart` injects its relative path, status, and complete next-step section. `Stop` checks freshness, mode-aware update guidance, one-shot loop protection, and secret-shaped content. Both are read paths with respect to the journal directory: neither creates it. +- secrets or credential fragments; +- customer or personal data; +- large logs; +- duplicated plan prose; +- unverified claims. -Freshness means the journal moved with the work. A timestamp that advances while the journal's content does not, in a session where other files changed, is not freshness. +Redact a credential to a key name, an env-var reference, or an explicit placeholder. A redaction must not itself read +as a credential, so conforming redactions pass the check. By default, a journal write whose new text looks like a +credential is denied. The denial names the line and the rule, never the value. A line that a human has verified may +carry ``. From ad49b2cad8fb1a3380b6261ef7c7f57ada6e48bb Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Wed, 21 Oct 2026 22:51:08 +0530 Subject: [PATCH 11/14] docs: record live Claude Code and Codex runs of the 2.0 hooks --- docs/verification.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/verification.md b/docs/verification.md index e8a0b85..731ad80 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -36,8 +36,17 @@ A snapshot, never rewritten. Python 3.14 and Claude Code 2.1.306 / Codex CLI 0.1 scratch v1 installation, which it migrated. `./doctor.sh` passed on each. - Simulated sessions piped hook JSON into the installed script: start, edit, commit, Stop nudge, journal write, Stop quiet, SessionEnd. -- Live Claude Code and Codex sessions against 2.0 were **not** exercised in this record; the hook input and output - shapes were checked against each runtime's published hook reference. +- **Live Claude Code 2.1.306** (headless `claude -p`, Haiku, scratch repository with a 2.0 install): the stream + recorded `hook_started`/`hook_response` for SessionStart, PreToolUse, PostToolUse and Stop. The hook trace showed + `SessionStart` → `bind` (the agent's journal write) → `stop: fresh` → `SessionEnd`, which stamped the journal's + `**Sessions:**` line and set it `PAUSED`. In the session that built this release, the installed PreToolUse hook + also denied a journal write by a `JOURNAL_ROLE=worker` session, as designed. +- **Live Codex CLI 0.163.0** (`codex exec`, scratch repository, `--dangerously-bypass-hook-trust` for that one + invocation): PostToolUse recorded the `apply_patch` edit and the `git commit` under Codex's turn id; Stop nudged + once ("this turn landed work but this session has no journal yet"); the continuation's Stop was skipped as + `already-nudged`; SessionEnd fired. Codex's own transcript is not parsed: the hooks' turn record carried it. +- Not exercised live: IN USE between two concurrent live sessions, `/clear`, and forks. The scenario suite covers + them with synthetic hook input. ## Adoption record — 2026-07-13, macOS (1.0, shell hooks) From 3499ec7d2e489496ef329574d9f2c0d9b7c2feaf Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Wed, 21 Oct 2026 23:38:08 +0530 Subject: [PATCH 12/14] feat: build spine promotion into the hook and judge the session's own journal The 1.x extras/spine-promotion shell hook picked the newest journal by mtime and blocked on every Stop once a DETAILED journal passed the threshold. It now lives in session-journal.py behind SPINE_PROMOTION_THRESHOLD (off by default): it checks the journal this session is bound to, only on a landed turn, and asks once per journal. The installer removes the old Stop entry and script and carries its SPINE_PROMOTION_* settings onto the generated Stop command (threshold 6 when none was set); the doctor flags a leftover entry. --- .claude/hooks/session-journal.py | 33 ++++++++- .github/workflows/ci.yml | 3 +- ADOPT.md | 9 ++- CHANGELOG.md | 5 ++ README.md | 11 ++- codex/skills/session-journal/SKILL.md | 1 + docs/verification.md | 8 +- doctor.sh | 6 +- extras/spine-promotion/README.md | 10 --- .../journal-spine-promotion-nudge.sh | 52 ------------- hooks/session-journal.py | 33 ++++++++- install.sh | 29 +++++++- skills/session-journal/SKILL.md | 1 + tests/install.bats | 18 +++++ tests/spine-promotion.bats | 74 ------------------- tests/test_journal.py | 68 +++++++++++++++++ 16 files changed, 203 insertions(+), 158 deletions(-) delete mode 100644 extras/spine-promotion/README.md delete mode 100755 extras/spine-promotion/journal-spine-promotion-nudge.sh delete mode 100644 tests/spine-promotion.bats diff --git a/.claude/hooks/session-journal.py b/.claude/hooks/session-journal.py index 0f71e97..9ca8f7d 100755 --- a/.claude/hooks/session-journal.py +++ b/.claude/hooks/session-journal.py @@ -35,6 +35,8 @@ JOURNAL_LIVE_MIN (45) JOURNAL_LIVE_MAX_H (24) JOURNAL_NUDGE_MIN (20) JOURNAL_MAX_KB (40) JOURNAL_PAUSE_ON_END 1 (default) marks an ACTIVE journal PAUSED when its session ends; 0 leaves it JOURNAL_TRANSCRIPTS_DIR where `reconstruct` looks for transcripts (default: ~/.claude/projects) + SPINE_PROMOTION_THRESHOLD opt-in: once per journal, ask to promote a DETAILED journal with this many milestones + SPINE_PROMOTION_TARGET_HINT where that durable plan should go (default: docs/plans/) """ from __future__ import annotations @@ -1009,6 +1011,29 @@ def on_post_tool(ctx, inp): return None +def spine_promotion(ctx, lease): + """Opt-in (SPINE_PROMOTION_THRESHOLD): once per journal, ask this session to promote a DETAILED journal that keeps + accumulating milestones into a durable plan. It judges the session's OWN journal, never the newest file.""" + try: + threshold = int(os.environ.get('SPINE_PROMOTION_THRESHOLD') or 0) + except ValueError: + threshold = 0 + journal = lease.get('journal') + if threshold <= 0 or not journal or lease.get('spine_nudged') == journal: + return None + text = read(ctx.jpath(journal)) + if text is None or is_thin(section(text, 'Live plan pointer').split('\n')[0] if section(text, 'Live plan pointer') + else 'none'): + return None + count = sum(1 for line in section(text, 'Milestones').splitlines() if re.match(r'\s*[-*] ', line)) + if count < threshold: + return None + hint = os.environ.get('SPINE_PROMOTION_TARGET_HINT') or 'docs/plans/' + return (f"SESSION-JOURNAL-SPINE-PROMOTION: YOUR journal {ctx.jpath(journal)} is DETAILED and has {count} " + f"milestones. Promote the recurring work into a durable plan or specification under {hint}, set " + "'## Live plan pointer' to it, and continue in THIN mode. (Asked once per journal.)") + + def on_stop(ctx, inp): sid = inp.get('session_id') or 'nosid' pre = ctx.lease(sid) or {} @@ -1068,9 +1093,15 @@ def on_stop(ctx, inp): result = 'nudge' lease['nudged'] = key nudge = (lease.get('journal'), committed, lease.get('unjournaled_turns', 0), since) + spine = None if nudge or inp.get('stop_hook_active') or not landed or lease['role'] == 'worker' \ + or skipping(lease) else spine_promotion(ctx, lease) + if spine: + lease['spine_nudged'] = lease['journal'] ctx.save_lease(lease) - ctx.trace(ev='stop', sid=sid, result=result, why=why, journal=lease.get('journal'), + ctx.trace(ev='stop', sid=sid, result='spine' if spine else result, why=why, journal=lease.get('journal'), unjournaled=lease.get('unjournaled_turns')) + if spine: + return {'decision': 'block', 'reason': spine} if not nudge: return None journal, committed, unjournaled, since = nudge diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a13df41..99ffaaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,11 +25,10 @@ jobs: run: brew install shellcheck - name: Validate shell syntax run: | - bash -n extras/spine-promotion/journal-spine-promotion-nudge.sh bash -n install.sh bash -n doctor.sh - name: Run shellcheck - run: shellcheck extras/spine-promotion/*.sh install.sh doctor.sh + run: shellcheck install.sh doctor.sh - name: Run the hook scenario tests # The runner's python3; the script itself is also checked to parse # under the oldest supported interpreter's grammar (3.8). diff --git a/ADOPT.md b/ADOPT.md index 458d8a5..6eabe57 100644 --- a/ADOPT.md +++ b/ADOPT.md @@ -17,7 +17,7 @@ Ask at most three questions, and only when discovery cannot answer them: 1. Which journal directory should be used, if root `sessions/` and `docs/sessions/` are equally plausible? 2. Should journals be committed (the default) or ignored locally? -3. Should the optional spine-promotion nudge be installed? +3. Should the optional spine-promotion nudge be turned on (`SPINE_PROMOTION_THRESHOLD`)? ## Phase A — survey without writing @@ -47,7 +47,7 @@ Inspect the following and record what you find for your final report: 10. **Python.** Confirm `python3 --version` reports 3.8 or later wherever agents run. Before writing anything, ask Question 3, unless the user's request already says whether to install the optional -spine-promotion module. +spine-promotion nudge. ## Phase B — install and adapt @@ -59,8 +59,9 @@ spine-promotion module. `TEMPLATE.md`. Adapt the sentinel blocks in CLAUDE.md and AGENTS.md. Keep the twin-sync sentence only when both files exist. A re-run of the installer preserves all of these. 3. Add any environment prefixes from skill §2 to the generated hook commands. A re-run preserves them. -4. If Question 3 opted into spine promotion, merge `extras/spine-promotion/journal-spine-promotion-nudge.sh` as an - extra Stop handler and test it. It is managed by hand, and you must also remove it by hand. +4. If Question 3 opted into spine promotion, add `SPINE_PROMOTION_THRESHOLD=` (and, if the plan directory is + not `docs/plans/`, `SPINE_PROMOTION_TARGET_HINT=`) to the front of the generated Stop command. A re-run of + the installer preserves it. Never delete or rewrite an existing user hook. Never commit unless the user asked or repository conventions already authorize it. Never put a real or realistic credential in a demonstration journal. If a settings file contains diff --git a/CHANGELOG.md b/CHANGELOG.md index fce8f0c..bbd8f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,11 @@ sessions, and several hundred journals. Its generic findings, in the order they - The `session-journal` agent skill (`skills/session-journal/`), with a Codex mirror that carries `agents/openai.yaml` (`codex/skills/session-journal/`). The installer places them in `.claude/skills/` and `.agents/skills/`. `ADOPT.md` now routes the adopting agent through the skill. +- Spine promotion is built in and judges the session's own journal, not the newest file. Set + `SPINE_PROMOTION_THRESHOLD` (off by default); it asks once per journal instead of on every Stop. The 1.x shell + module `extras/spine-promotion/` is removed. The installer removes its Stop entry and its script from + `.claude/hooks/`, and carries its settings onto the generated Stop command, with a threshold of 6 when none was + set. - `--no-skill`. The doctor now checks all six events, the `|| true` safety net on each command, leftover v1 hooks, Python, and that local state stays out of git. It also runs the real hooks against a scratch state directory. - `tests/test_journal.py`: scenario tests for parallel sessions, IN USE, worktrees, `/clear`, forks, workers, diff --git a/README.md b/README.md index a79c3ab..805aae7 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,8 @@ session, set it in that agent's environment. | `JOURNAL_MAX_KB` | `40` | Size above which an agent is told to continue in a new journal. | | `JOURNAL_PAUSE_ON_END` | `1` | `0`: a session ending does not mark its ACTIVE journal PAUSED. | | `JOURNAL_TRANSCRIPTS_DIR` | `~/.claude/projects` | Where `reconstruct` looks for transcripts. | +| `SPINE_PROMOTION_THRESHOLD` | unset (off) | Milestone count at which a DETAILED journal is asked, once, to be promoted to a plan. | +| `SPINE_PROMOTION_TARGET_HINT` | `docs/plans/` | Where that plan should go. | ## Safety model @@ -197,10 +199,11 @@ This is a continuity aid, not a repository credential scanner. Keep your normal ## Optional spine promotion -Long-running DETAILED journals often mean a durable plan is missing. The optional module under -`extras/spine-promotion/` nudges after a configurable number of milestones and asks the agent to promote recurring -work into a plan. It is managed by hand: the installer does not wire it. It still judges the newest journal by -modification time. +Long-running DETAILED journals often mean a durable plan is missing. Set `SPINE_PROMOTION_THRESHOLD` (for example +`6`) on the generated Stop command. When this session's own DETAILED journal reaches that many milestones, Stop asks +once per journal for the work to be promoted into a plan under `SPINE_PROMOTION_TARGET_HINT` (default +`docs/plans/`), and for the journal to switch to THIN. It is off by default. The 1.x shell module +`extras/spine-promotion/` is gone: the installer removes its Stop entry and carries its settings over. ## Uninstall diff --git a/codex/skills/session-journal/SKILL.md b/codex/skills/session-journal/SKILL.md index 96fa786..995a358 100644 --- a/codex/skills/session-journal/SKILL.md +++ b/codex/skills/session-journal/SKILL.md @@ -48,6 +48,7 @@ Configure behaviour with environment prefixes on the generated hook commands. A | A private repository where the owner wants credentials recorded | `JOURNAL_SECRETS=warn`, only with the owner's explicit decision | | Sessions should not mark a journal PAUSED when they end | `JOURNAL_PAUSE_ON_END=0` | | Different lock or nudge timing | `JOURNAL_LIVE_MIN`, `JOURNAL_NUDGE_MIN`, `JOURNAL_MAX_KB` | +| Long DETAILED journals should be promoted into plans | `SPINE_PROMOTION_THRESHOLD=6` (asked once per journal), `SPINE_PROMOTION_TARGET_HINT` | Anything the hooks cannot know, such as the evidence vocabulary or which documents govern which work, goes in `/README.md`. diff --git a/docs/verification.md b/docs/verification.md index 731ad80..1e1a860 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -11,8 +11,8 @@ section are checked by `tests/docs.bats`, so they cannot quietly rot. Run these to reproduce the claim rather than trusting a number frozen here: ```bash -bash -n install.sh doctor.sh extras/spine-promotion/*.sh -shellcheck install.sh doctor.sh extras/spine-promotion/*.sh +bash -n install.sh doctor.sh +shellcheck install.sh doctor.sh python3 -m unittest discover -s tests -p 'test_*.py' bats tests ./doctor.sh . @@ -20,8 +20,8 @@ bats tests | Gate | Claim | | --- | --- | -| Python scenario tests | 53 scenario methods in `tests/test_journal.py` (the session-binding class runs twice, once under `docs/sessions`), all passing | -| bats-core | 66 checks, all passing | +| Python scenario tests | 58 scenario methods in `tests/test_journal.py` (the session-binding class runs twice, once under `docs/sessions`), all passing | +| bats-core | 60 checks, all passing | | Shellcheck and `bash -n` | clean for every shell entrypoint | | Self-check | `./doctor.sh .` passes against this repository's own installation | | Secrets | the runtime guard runs over every tracked journal in CI, plus a pinned full-history gitleaks scan | diff --git a/doctor.sh b/doctor.sh index 4661af4..19b85ae 100755 --- a/doctor.sh +++ b/doctor.sh @@ -84,8 +84,8 @@ for candidate_settings in "$SETTINGS" "$CODEX_SETTINGS"; do else fail "$label wires all six journal hooks" "missing:$missing (re-run ./install.sh)" fi - if [ "$HAVE_JQ" = true ] && jq -e '[.hooks // {} | .[]?[]? | .hooks[]? | .command // "" | select(test("session-journal-(pointer|nudge)\\.sh"))] | length > 0' "$candidate_settings" >/dev/null 2>&1; then - fail "$label carries no v1 hooks" "v1 pointer/nudge hooks still registered; re-run ./install.sh to migrate" + if [ "$HAVE_JQ" = true ] && jq -e '[.hooks // {} | .[]?[]? | .hooks[]? | .command // "" | select(test("session-journal-(pointer|nudge)\\.sh|journal-spine-promotion-nudge\\.sh"))] | length > 0' "$candidate_settings" >/dev/null 2>&1; then + fail "$label carries no v1 hooks" "1.x pointer, nudge, or spine-promotion hooks still registered; re-run ./install.sh to migrate" fi done [ "$RUNTIMES" -gt 0 ] || fail "a runtime is wired" "no session-journal.py hooks in .claude/settings.json or .codex/hooks.json" @@ -100,7 +100,7 @@ elif ! VERSION_OUT=$(python3 "$SCRIPT" version 2>/dev/null); then else pass ".claude/hooks/session-journal.py runs (version $VERSION_OUT)" fi -for legacy in session-journal-pointer.sh session-journal-nudge.sh; do +for legacy in session-journal-pointer.sh session-journal-nudge.sh journal-spine-promotion-nudge.sh; do [ ! -e "$TARGET/.claude/hooks/$legacy" ] || note "v1 script .claude/hooks/$legacy is still on disk; ./install.sh removes it" done diff --git a/extras/spine-promotion/README.md b/extras/spine-promotion/README.md deleted file mode 100644 index 37b980c..0000000 --- a/extras/spine-promotion/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Optional spine-promotion nudge - -Install this extra Stop hook only in long-running repositories where DETAILED journals repeatedly accumulate. At six milestones by default, it asks the agent to promote recurring work into a durable plan or specification and flip the journal to THIN. - -Configuration: - -- `SPINE_PROMOTION_THRESHOLD` — milestone count, default `6`. -- `SPINE_PROMOTION_TARGET_HINT` — suggested durable-doc directory, default `docs/plans/`. - -Merge it as an additional Stop handler; do not replace the freshness and secrets hook. This optional entry is manually managed: remove its hook entry and installed script manually when uninstalling it, because the core installer owns only the pointer and freshness/secrets hooks. A repository can also build a reverse index from file paths to journals for a local documentation site, but that site-specific index is intentionally outside this portable module. diff --git a/extras/spine-promotion/journal-spine-promotion-nudge.sh b/extras/spine-promotion/journal-spine-promotion-nudge.sh deleted file mode 100755 index 7a442f7..0000000 --- a/extras/spine-promotion/journal-spine-promotion-nudge.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -# Optional Stop hook for recurring DETAILED journals. -# -# session-journal — https://github.com/dctmfoo/session-journal (MIT) - -set -e - -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" -SESSIONS_DIR="${SESSION_JOURNAL_DIR:-$PROJECT_DIR/sessions}" -case "$SESSIONS_DIR" in /*) ;; *) SESSIONS_DIR="$PROJECT_DIR/$SESSIONS_DIR" ;; esac -THRESHOLD="${SPINE_PROMOTION_THRESHOLD:-6}" -TARGET_HINT="${SPINE_PROMOTION_TARGET_HINT:-docs/plans/}" - -# Newest journal: mtime leads, filename settles ties. See the long note in -# session-journal-pointer.sh for why mtime alone picks the oldest after a clone. -LATEST="" -LATEST_BASE="" -for candidate in "$SESSIONS_DIR"/*.md; do - [ -e "$candidate" ] || continue - base=$(basename "$candidate") - case "$base" in README.md|TEMPLATE.md) continue ;; esac - if [ -z "$LATEST" ]; then - LATEST=$candidate - LATEST_BASE=$base - elif [ "$candidate" -nt "$LATEST" ]; then - LATEST=$candidate - LATEST_BASE=$base - elif [ ! "$LATEST" -nt "$candidate" ] && [ "$base" \> "$LATEST_BASE" ]; then - LATEST=$candidate - LATEST_BASE=$base - fi -done -[ -n "$LATEST" ] || { printf '{}\n'; exit 0; } - -# Headings match case-insensitively, as the specification requires and as both -# core hooks do. Left exact, this hook read a title-cased THIN journal as -# DETAILED and blocked Stop with a message that was simply untrue. -PLAN=$(awk 'tolower($0) ~ /^##[[:space:]]+live plan pointer[[:space:]]*$/{inside=1;next} inside&&/^## /{exit} inside&&NF{print;exit}' "$LATEST" 2>/dev/null || true) -[ -z "$PLAN" ] || [ "$(printf '%s' "$PLAN" | tr '[:upper:]' '[:lower:]')" = "none" ] || { printf '{}\n'; exit 0; } - -# An unreadable journal must not hand raw awk diagnostics to the agent as a Stop -# block. Failing open here is correct: this is an optional advisory nudge, and -# the core freshness and secrets hooks own the enforcement path. -COUNT=$(awk 'tolower($0) ~ /^##[[:space:]]+milestones[[:space:]]*$/{inside=1;next} inside&&/^## /{exit} inside&&/^[[:space:]]*[-*] /{count++} END{print count+0}' "$LATEST" 2>/dev/null || printf '0') -case "$COUNT" in - ''|*[!0-9]*) COUNT=0 ;; -esac -if [ "$COUNT" -ge "$THRESHOLD" ]; then - echo "SESSION-JOURNAL-SPINE-PROMOTION: this DETAILED journal has $COUNT milestones. Promote recurring work into a durable plan or specification under $TARGET_HINT, point the journal to it, and continue in THIN mode." >&2 - exit 2 -fi -printf '{}\n' diff --git a/hooks/session-journal.py b/hooks/session-journal.py index 0f71e97..9ca8f7d 100644 --- a/hooks/session-journal.py +++ b/hooks/session-journal.py @@ -35,6 +35,8 @@ JOURNAL_LIVE_MIN (45) JOURNAL_LIVE_MAX_H (24) JOURNAL_NUDGE_MIN (20) JOURNAL_MAX_KB (40) JOURNAL_PAUSE_ON_END 1 (default) marks an ACTIVE journal PAUSED when its session ends; 0 leaves it JOURNAL_TRANSCRIPTS_DIR where `reconstruct` looks for transcripts (default: ~/.claude/projects) + SPINE_PROMOTION_THRESHOLD opt-in: once per journal, ask to promote a DETAILED journal with this many milestones + SPINE_PROMOTION_TARGET_HINT where that durable plan should go (default: docs/plans/) """ from __future__ import annotations @@ -1009,6 +1011,29 @@ def on_post_tool(ctx, inp): return None +def spine_promotion(ctx, lease): + """Opt-in (SPINE_PROMOTION_THRESHOLD): once per journal, ask this session to promote a DETAILED journal that keeps + accumulating milestones into a durable plan. It judges the session's OWN journal, never the newest file.""" + try: + threshold = int(os.environ.get('SPINE_PROMOTION_THRESHOLD') or 0) + except ValueError: + threshold = 0 + journal = lease.get('journal') + if threshold <= 0 or not journal or lease.get('spine_nudged') == journal: + return None + text = read(ctx.jpath(journal)) + if text is None or is_thin(section(text, 'Live plan pointer').split('\n')[0] if section(text, 'Live plan pointer') + else 'none'): + return None + count = sum(1 for line in section(text, 'Milestones').splitlines() if re.match(r'\s*[-*] ', line)) + if count < threshold: + return None + hint = os.environ.get('SPINE_PROMOTION_TARGET_HINT') or 'docs/plans/' + return (f"SESSION-JOURNAL-SPINE-PROMOTION: YOUR journal {ctx.jpath(journal)} is DETAILED and has {count} " + f"milestones. Promote the recurring work into a durable plan or specification under {hint}, set " + "'## Live plan pointer' to it, and continue in THIN mode. (Asked once per journal.)") + + def on_stop(ctx, inp): sid = inp.get('session_id') or 'nosid' pre = ctx.lease(sid) or {} @@ -1068,9 +1093,15 @@ def on_stop(ctx, inp): result = 'nudge' lease['nudged'] = key nudge = (lease.get('journal'), committed, lease.get('unjournaled_turns', 0), since) + spine = None if nudge or inp.get('stop_hook_active') or not landed or lease['role'] == 'worker' \ + or skipping(lease) else spine_promotion(ctx, lease) + if spine: + lease['spine_nudged'] = lease['journal'] ctx.save_lease(lease) - ctx.trace(ev='stop', sid=sid, result=result, why=why, journal=lease.get('journal'), + ctx.trace(ev='stop', sid=sid, result='spine' if spine else result, why=why, journal=lease.get('journal'), unjournaled=lease.get('unjournaled_turns')) + if spine: + return {'decision': 'block', 'reason': spine} if not nudge: return None journal, committed, unjournaled, since = nudge diff --git a/install.sh b/install.sh index 90c74df..e70fc86 100755 --- a/install.sh +++ b/install.sh @@ -90,7 +90,12 @@ STATE_DIR="$TARGET/.claude/state/session-journal" # scripts; 2.0 replaces both with one Python program. Matching all three names # is what lets an install double as the v1 -> v2 migration. OWNED_PATTERN="session-journal\\.py|session-journal-pointer\\.sh|session-journal-nudge\\.sh" -LEGACY_SCRIPTS="session-journal-pointer.sh session-journal-nudge.sh" +# The optional 1.x spine-promotion Stop hook is now built into session-journal.py +# (SPINE_PROMOTION_THRESHOLD). Its groups are removed with ours, and its +# settings are carried onto the generated Stop command. +SPINE_PATTERN="journal-spine-promotion-nudge\\.sh" +REMOVE_PATTERN="$OWNED_PATTERN|$SPINE_PATTERN" +LEGACY_SCRIPTS="session-journal-pointer.sh session-journal-nudge.sh journal-spine-promotion-nudge.sh" validate_json_file() { local file=$1 @@ -171,7 +176,7 @@ remove_hooks() { [ -e "$file" ] || return 0 validate_json_file "$file" local tmp="$file.session-journal.tmp" - jq --arg owned "$OWNED_PATTERN" ' + jq --arg owned "$REMOVE_PATTERN" ' if (.hooks | type) == "object" then .hooks |= with_entries(.value |= ( [ .[]? | .hooks = [ (.hooks // [])[] | select((.command // "") | test($owned) | not) ] @@ -191,6 +196,14 @@ existing_command() { ' "$file" 2>/dev/null || printf '' } +# The 1.x spine-promotion command registered under Stop, if any. +spine_command() { + [ -e "$1" ] || return 0 + jq -r --arg spine "$SPINE_PATTERN" ' + [(.hooks // {}).Stop[]? | .hooks[]? | .command // ""] | map(select(test($spine))) | first // "" + ' "$1" 2>/dev/null || printf '' +} + # Leading NAME=value assignments of a command -- the one place a shell env # prefix can legally live. Quoted values are parsed rather than stripped. # `keep` is "all", or "foreign" to drop SESSION_JOURNAL_DIR, which the @@ -342,7 +355,7 @@ chmod +x "$SCRIPT" for legacy in $LEGACY_SCRIPTS; do if [ -e "$TARGET/.claude/hooks/$legacy" ]; then rm -f "$TARGET/.claude/hooks/$legacy" - echo "Removed the v1 hook script .claude/hooks/$legacy (replaced by $SCRIPT_NAME)." + echo "Removed the 1.x hook script .claude/hooks/$legacy (replaced by $SCRIPT_NAME)." fi done @@ -391,6 +404,16 @@ merge_hooks() { codex:SessionEnd) timeout=3 ;; esac env=$(parse_env_prefix "$(existing_command "$file" "$event")" foreign) + if [ "$event" = Stop ]; then + spine=$(spine_command "$file") + if [ -n "$spine" ]; then + spine_env=$(parse_env_prefix "$spine" all | tr ' ' '\n' | grep '^SPINE_PROMOTION_' | tr '\n' ' ') + case "$env$spine_env" in *SPINE_PROMOTION_THRESHOLD=*) ;; *) spine_env="${spine_env}SPINE_PROMOTION_THRESHOLD=6 " ;; esac + case "$env" in *SPINE_PROMOTION_*) spine_env="" ;; esac + env="$env$spine_env" + echo "Migrated the spine-promotion Stop hook into $SCRIPT_NAME (${spine_env:-settings already present})." + fi + fi [ -z "$env" ] || echo "Preserved environment overrides on the $event command: $env" # `|| true`: a missing interpreter or script exits 127 or 2, and exit 2 # from a hook blocks the tool call or the turn. A journal hook must never. diff --git a/skills/session-journal/SKILL.md b/skills/session-journal/SKILL.md index 96fa786..995a358 100644 --- a/skills/session-journal/SKILL.md +++ b/skills/session-journal/SKILL.md @@ -48,6 +48,7 @@ Configure behaviour with environment prefixes on the generated hook commands. A | A private repository where the owner wants credentials recorded | `JOURNAL_SECRETS=warn`, only with the owner's explicit decision | | Sessions should not mark a journal PAUSED when they end | `JOURNAL_PAUSE_ON_END=0` | | Different lock or nudge timing | `JOURNAL_LIVE_MIN`, `JOURNAL_NUDGE_MIN`, `JOURNAL_MAX_KB` | +| Long DETAILED journals should be promoted into plans | `SPINE_PROMOTION_THRESHOLD=6` (asked once per journal), `SPINE_PROMOTION_TARGET_HINT` | Anything the hooks cannot know, such as the evidence vocabulary or which documents govern which work, goes in `/README.md`. diff --git a/tests/install.bats b/tests/install.bats index 53a0b3c..f2089cb 100644 --- a/tests/install.bats +++ b/tests/install.bats @@ -346,3 +346,21 @@ JSON run sh -c "cd '$TARGET' && printf '{}' | $stop" [ "$status" -eq 0 ] } + +@test "an install migrates the 1.x spine-promotion hook into the script" { + mkdir -p "$TARGET/.claude/hooks" + printf '#!/bin/bash\n' > "$TARGET/.claude/hooks/journal-spine-promotion-nudge.sh" + cat > "$TARGET/.claude/settings.json" <<'JSON' +{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"SPINE_PROMOTION_TARGET_HINT=specs/ \"$(git rev-parse --show-toplevel)/.claude/hooks/journal-spine-promotion-nudge.sh\""}]}]}} +JSON + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 0 ] + [[ "$output" == *"Migrated the spine-promotion Stop hook"* ]] + [ ! -e "$TARGET/.claude/hooks/journal-spine-promotion-nudge.sh" ] + ! grep -q 'journal-spine-promotion-nudge' "$TARGET/.claude/settings.json" + jq -e '.hooks.Stop | length == 1' "$TARGET/.claude/settings.json" >/dev/null + jq -e '.hooks.Stop[0].hooks[0].command | startswith("SPINE_PROMOTION_TARGET_HINT=specs/ SPINE_PROMOTION_THRESHOLD=6 python3")' "$TARGET/.claude/settings.json" >/dev/null + run "$BATS_TEST_DIRNAME/../install.sh" "$TARGET" + [ "$status" -eq 0 ] + jq -e '.hooks.Stop[0].hooks[0].command | startswith("SPINE_PROMOTION_TARGET_HINT=specs/ SPINE_PROMOTION_THRESHOLD=6 python3")' "$TARGET/.claude/settings.json" >/dev/null +} diff --git a/tests/spine-promotion.bats b/tests/spine-promotion.bats deleted file mode 100644 index 43e5ab6..0000000 --- a/tests/spine-promotion.bats +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bats - -load test_helper - -setup() { - setup_workspace - export SPINE="$BATS_TEST_DIRNAME/../extras/spine-promotion/journal-spine-promotion-nudge.sh" -} -teardown() { teardown_workspace; } - -add_milestones() { - local journal=$1 count=$2 tmp="$journal.tmp" - awk -v count="$count" ' - /^## Milestones$/ { - print - for (i = 1; i <= count; i++) print "- Synthetic milestone " i "." - skipping=1 - next - } - skipping && /^## / { skipping=0 } - !skipping { print } - ' "$journal" > "$tmp" - mv "$tmp" "$journal" -} - -@test "no journal succeeds" { - run "$SPINE" - [ "$status" -eq 0 ] -} - -@test "THIN journal skips promotion at the threshold" { - journal="$(write_journal 2026-01-02-0304-thin.md docs/plans/work.md)" - add_milestones "$journal" 6 - run "$SPINE" - [ "$status" -eq 0 ] -} - -@test "DETAILED journal below threshold succeeds" { - journal="$(write_journal)" - add_milestones "$journal" 5 - run "$SPINE" - [ "$status" -eq 0 ] -} - -@test "DETAILED journal at threshold blocks with default hint" { - journal="$(write_journal)" - add_milestones "$journal" 6 - run "$SPINE" - [ "$status" -eq 2 ] - [[ "$output" == *"docs/plans/"* ]] -} - -@test "threshold and target hint overrides are honored" { - journal="$(write_journal)" - add_milestones "$journal" 2 - run env SPINE_PROMOTION_THRESHOLD=2 SPINE_PROMOTION_TARGET_HINT=specs/ "$SPINE" - [ "$status" -eq 2 ] - [[ "$output" == *"specs/"* ]] -} - -@test "a title-cased THIN journal is recognised as THIN" { - journal="$SESSION_JOURNAL_DIR/2026-01-02-0304-cased.md" - printf '**Status:** ACTIVE\n\n## Live Plan Pointer\ndocs/plans/release.md\n\n## Milestones\n- a\n- b\n- c\n- d\n- e\n- f\n' > "$journal" - run "$BATS_TEST_DIRNAME/../extras/spine-promotion/journal-spine-promotion-nudge.sh" - [ "$status" -eq 0 ] -} - -@test "a title-cased DETAILED journal still counts its milestones" { - journal="$SESSION_JOURNAL_DIR/2026-01-02-0304-cased.md" - printf '**Status:** ACTIVE\n\n## Live Plan Pointer\nnone\n\n## Milestones\n- a\n- b\n- c\n- d\n- e\n- f\n' > "$journal" - run "$BATS_TEST_DIRNAME/../extras/spine-promotion/journal-spine-promotion-nudge.sh" - [ "$status" -eq 2 ] - [[ "$output" == *"6 milestones"* ]] -} diff --git a/tests/test_journal.py b/tests/test_journal.py index 43dd635..5139ad5 100644 --- a/tests/test_journal.py +++ b/tests/test_journal.py @@ -460,6 +460,74 @@ def test_oversized_journal_is_flagged_once(self): self.assertIn('continue it in a new journal with Continues:', out) +class SpinePromotion(Lab): + """Ported from the 1.x shell extra, which judged the newest journal and blocked on every Stop.""" + + def detailed(self, name, milestones, pointer='none'): + body = JOURNAL.format(title=name, pointer=pointer).replace( + '- 10:00 - opened\n', ''.join(f'- milestone {i}\n' for i in range(milestones))) + return body + + def turn(self, sid, t, name, milestones, env, pointer='none', prompt='p'): + t.human('work') + self.write_journal(sid, t, name, env=env, text=self.detailed(name, milestones, pointer)) + self.edit(t) + return self.hook('Stop', sid, env=env, transcript_path=t.path, stop_hook_active=False, prompt_id=prompt) + + def test_off_by_default(self): + t = Transcript(os.path.join(self.tmp, 's0.jsonl')) + self.hook('SessionStart', '5e5e0000-1', source='startup', transcript_path=t.path) + self.assertIsNone(self.turn('5e5e0000-1', t, '2026-01-13-0900-long.md', 9, {})) + + def test_detailed_journal_at_threshold_is_asked_once(self): + env = {'SPINE_PROMOTION_THRESHOLD': '6'} + t = Transcript(os.path.join(self.tmp, 's1.jsonl')) + self.hook('SessionStart', '5e5e0000-2', env=env, source='startup', transcript_path=t.path) + self.assertIsNone(self.turn('5e5e0000-2', t, '2026-01-13-0900-long.md', 5, env, prompt='a'), 'below threshold') + d = self.turn('5e5e0000-2', t, '2026-01-13-0900-long.md', 6, env, prompt='b') + self.assertEqual(d['decision'], 'block') + self.assertIn('6 milestones', d['reason']) + self.assertIn('docs/plans/', d['reason']) + self.assertIsNone(self.hook('Stop', '5e5e0000-2', env=env, transcript_path=t.path, stop_hook_active=True, + prompt_id='b')) + self.assertIsNone(self.turn('5e5e0000-2', t, '2026-01-13-0900-long.md', 9, env, prompt='c'), 'once per journal') + + def test_thin_journal_is_never_asked(self): + env = {'SPINE_PROMOTION_THRESHOLD': '2'} + t = Transcript(os.path.join(self.tmp, 's2.jsonl')) + self.hook('SessionStart', '5e5e0000-3', env=env, source='startup', transcript_path=t.path) + self.assertIsNone(self.turn('5e5e0000-3', t, '2026-01-13-0901-thin.md', 9, env, pointer='docs/plans/x.md')) + + def test_judges_this_sessions_journal_not_the_newest(self): + env = {'SPINE_PROMOTION_THRESHOLD': '3', 'SPINE_PROMOTION_TARGET_HINT': 'specs/'} + p = self.jfile('2026-01-13-1000-someone-elses.md') + open(p, 'w').write(self.detailed('other', 20)) + os.utime(p, (time.time() + 60,) * 2) # the newest file belongs to nobody in this session + t = Transcript(os.path.join(self.tmp, 's3.jsonl')) + self.hook('SessionStart', '5e5e0000-4', env=env, source='startup', transcript_path=t.path) + self.assertIsNone(self.turn('5e5e0000-4', t, '2026-01-13-0902-mine.md', 2, env, prompt='a')) + d = self.turn('5e5e0000-4', t, '2026-01-13-0902-mine.md', 3, env, prompt='b') + self.assertIn('2026-01-13-0902-mine.md', d['reason']) + self.assertIn('specs/', d['reason']) + self.assertNotIn('someone-elses', d['reason']) + + def test_title_cased_headings_are_recognised(self): + env = {'SPINE_PROMOTION_THRESHOLD': '3'} + t = Transcript(os.path.join(self.tmp, 's4.jsonl')) + self.hook('SessionStart', '5e5e0000-5', env=env, source='startup', transcript_path=t.path) + t.human('work') + text = self.detailed('cased', 4, pointer='docs/plans/x.md').replace('## Live plan pointer', '## Live Plan Pointer') + self.write_journal('5e5e0000-5', t, '2026-01-13-0903-cased.md', env=env, text=text) + self.edit(t) + self.assertIsNone(self.hook('Stop', '5e5e0000-5', env=env, transcript_path=t.path, stop_hook_active=False)) + text = self.detailed('cased', 4).replace('## Milestones', '## MILESTONES') + t.human('more') + self.write_journal('5e5e0000-5', t, '2026-01-13-0903-cased.md', env=env, text=text) + self.edit(t) + d = self.hook('Stop', '5e5e0000-5', env=env, transcript_path=t.path, stop_hook_active=False) + self.assertIn('4 milestones', d['reason']) + + class Lifecycle(Lab): def test_session_end_pauses_and_records_session_then_reconstruct(self): t = Transcript(os.path.join(self.tmp, '55555555-aa.jsonl')) From 2e66d0db5d2b1ecafda10faab70f536a9b1ca678 Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Wed, 21 Oct 2026 23:55:12 +0530 Subject: [PATCH 13/14] fix: never leave a committed journal modified at session end A session that committed everything had its journal edited by SessionEnd (Sessions line, ACTIVE to PAUSED), leaving an uncommitted change after the agent was gone. When the journal is clean in git, the stamp now waits in the lease: candidates show it as PAUSED (session ended), the next session that changes the journal writes the pending line when it ends, and sweep applies it. A later write supersedes a pending pause. --- .claude/hooks/session-journal.py | 57 ++++++++++++++++++++++++++++++-- CHANGELOG.md | 4 ++- docs/verification.md | 2 +- hooks/session-journal.py | 57 ++++++++++++++++++++++++++++++-- spec/session-journal-spec.md | 5 +++ templates/sessions-README.md | 2 +- tests/test_journal.py | 43 ++++++++++++++++++++++++ 7 files changed, 161 insertions(+), 9 deletions(-) diff --git a/.claude/hooks/session-journal.py b/.claude/hooks/session-journal.py index 9ca8f7d..c7a6f6f 100755 --- a/.claude/hooks/session-journal.py +++ b/.claude/hooks/session-journal.py @@ -636,14 +636,20 @@ def candidates(ctx, lease, limit=5, first=None): for line in git('status', '--porcelain', '--', ctx.jdir, cwd=ctx.main).splitlines(): dirty.add(os.path.basename(line[3:].strip().strip('"'))) session_writes = {} + paused = {} # journal -> end time of a session whose PAUSED stamp is pending for l in ctx.leases(): for w in l.get('writes') or []: session_writes[w[0]] = max(session_writes.get(w[0], 0), w[1]) + if l.get('stamp_pending') == 'pause' and l.get('journal'): + paused[l['journal']] = max(paused.get(l['journal'], 0), l.get('ended') or 0) rows = [] for p in ctx.journals()[-300:]: c = card(ctx, os.path.basename(p)) if not c or c['status'] in CLOSED: continue + if c['status'] == 'ACTIVE' and c['journal'] in paused and \ + paused[c['journal']] >= session_writes.get(c['journal'], 0): + c['label'] = 'PAUSED (session ended)' # the header still says ACTIVE until it is stamped owners = ctx.owners(c['journal'], me=lease) last = session_writes.get(c['journal']) or c['mtime'] score = -((time.time() - last) / 3600.0) @@ -1154,6 +1160,34 @@ def stamp_session_line(ctx, name, lease, status=None): os.replace(tmp, p) +AUTO_PAUSED = 'PAUSED (auto: session ended without DONE)' + + +def clean_in_git(ctx, name): + """True when git tracks the journal and it has no uncommitted change, so a hook edit would create one.""" + p = ctx.jpath(name) + return bool(git('ls-files', '--', p, cwd=ctx.main)) and not git('status', '--porcelain', '--', p, cwd=ctx.main) + + +def settle_stamps(ctx, name, own=None, own_status=None, pending_status=False): + """Write the header lines ended sessions left pending for this journal, oldest first, then this session's own. + A pending PAUSED is applied only by `sweep` (pending_status) and only from the latest ended session: a later + session that wrote the journal has superseded an earlier pause.""" + pend = sorted((l for l in ctx.leases() if l.get('journal') == name and l.get('stamp_pending') + and l.get('sid') != (own or {}).get('sid')), key=lambda l: l.get('ended') or 0) + for i, l in enumerate(pend): + last = i == len(pend) - 1 and own is None + stamp_session_line(ctx, name, l, status=AUTO_PAUSED if pending_status and last and + l.get('stamp_pending') == 'pause' else None) + with ctx.lock(): + cur = ctx.lease(l['sid']) or l + cur.pop('stamp_pending', None) + ctx.save_lease(cur, touch=False) + if own is not None: + stamp_session_line(ctx, name, own, status=own_status) + return len(pend) + + def on_session_end(ctx, inp): reason = inp.get('reason') or 'other' with ctx.lock(): @@ -1163,12 +1197,22 @@ def on_session_end(ctx, inp): lease['ended'] = time.time() lease['end_reason'] = reason ctx.save_lease(lease) + deferred = False if lease.get('journal') and lease['role'] != 'worker' and not ctx.owners(lease['journal']): # /clear starts a new session in the same window, which usually carries on: record it, keep the status. pause = reason != 'clear' and os.environ.get('JOURNAL_PAUSE_ON_END', '1') not in ('0', 'false', 'no') - stamp_session_line(ctx, lease['journal'], lease, - status='PAUSED (auto: session ended without DONE)' if pause else None) - ctx.trace(ev='SessionEnd', sid=lease['sid'], journal=lease.get('journal'), reason=reason) + if clean_in_git(ctx, lease['journal']): + # Everything was committed. Editing the journal now would leave a change nobody asked for after the + # agent has gone, so the header lines wait for the next session that changes the journal anyway (its + # own SessionEnd), or for `sweep`. Candidates show the pause meanwhile. + with ctx.lock(): + cur = ctx.lease(lease['sid']) or lease + cur['stamp_pending'] = 'pause' if pause else 'record' + ctx.save_lease(cur, touch=False) + deferred = True + else: + settle_stamps(ctx, lease['journal'], own=lease, own_status=AUTO_PAUSED if pause else None) + ctx.trace(ev='SessionEnd', sid=lease['sid'], journal=lease.get('journal'), reason=reason, deferred=deferred) return None @@ -1442,6 +1486,13 @@ def cmd_sweep(ctx, args): ctx.save_lease(l, touch=False) if l.get('journal') and not ctx.owners(l['journal']): stamp_session_line(ctx, l['journal'], l) + pending = sorted({l['journal'] for l in ctx.leases() if l.get('stamp_pending') and l.get('journal')}) + for n in pending: + if ctx.owners(n) or not os.path.exists(ctx.jpath(n)): + continue + changes.append(f'stamp {ctx.show(n)} (sessions that ended with everything committed)') + if not dry: + settle_stamps(ctx, n, pending_status=True) gdates = last_commit_dates(ctx) if by_git else {} if by_git and not gdates and git('ls-files', '--', ctx.jdir, cwd=ctx.main): print('sweep --by-git: git log over the journal directory returned nothing (timeout or error); refusing to ' diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd8f8a..49d9a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,9 @@ sessions, and several hundred journals. Its generic findings, in the order they loop is needed. The rules are retuned for compound key names (`dbPassword=`, `SERVICE_API_KEY:`), prose, table cells, URL credentials, and CLI flags. The bare-hexadecimal rule is gone. - **Sessions end by marking an ACTIVE journal PAUSED**, and record themselves on a `**Sessions:**` line. `PAUSED` - joins the status vocabulary. `JOURNAL_PAUSE_ON_END=0` turns this off. + joins the status vocabulary. `JOURNAL_PAUSE_ON_END=0` turns this off. A journal that is committed and unchanged + is never edited at session end, because that would leave a change nobody asked for: the stamp waits in local state + until the next session that changes the journal ends, or until `sweep`. - **`SESSION_JOURNAL_DIR` is relative to the main checkout.** The installer rewrites the v1 absolute form. ### Removed diff --git a/docs/verification.md b/docs/verification.md index 1e1a860..5963748 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -20,7 +20,7 @@ bats tests | Gate | Claim | | --- | --- | -| Python scenario tests | 58 scenario methods in `tests/test_journal.py` (the session-binding class runs twice, once under `docs/sessions`), all passing | +| Python scenario tests | 60 scenario methods in `tests/test_journal.py` (the session-binding class runs twice, once under `docs/sessions`), all passing | | bats-core | 60 checks, all passing | | Shellcheck and `bash -n` | clean for every shell entrypoint | | Self-check | `./doctor.sh .` passes against this repository's own installation | diff --git a/hooks/session-journal.py b/hooks/session-journal.py index 9ca8f7d..c7a6f6f 100644 --- a/hooks/session-journal.py +++ b/hooks/session-journal.py @@ -636,14 +636,20 @@ def candidates(ctx, lease, limit=5, first=None): for line in git('status', '--porcelain', '--', ctx.jdir, cwd=ctx.main).splitlines(): dirty.add(os.path.basename(line[3:].strip().strip('"'))) session_writes = {} + paused = {} # journal -> end time of a session whose PAUSED stamp is pending for l in ctx.leases(): for w in l.get('writes') or []: session_writes[w[0]] = max(session_writes.get(w[0], 0), w[1]) + if l.get('stamp_pending') == 'pause' and l.get('journal'): + paused[l['journal']] = max(paused.get(l['journal'], 0), l.get('ended') or 0) rows = [] for p in ctx.journals()[-300:]: c = card(ctx, os.path.basename(p)) if not c or c['status'] in CLOSED: continue + if c['status'] == 'ACTIVE' and c['journal'] in paused and \ + paused[c['journal']] >= session_writes.get(c['journal'], 0): + c['label'] = 'PAUSED (session ended)' # the header still says ACTIVE until it is stamped owners = ctx.owners(c['journal'], me=lease) last = session_writes.get(c['journal']) or c['mtime'] score = -((time.time() - last) / 3600.0) @@ -1154,6 +1160,34 @@ def stamp_session_line(ctx, name, lease, status=None): os.replace(tmp, p) +AUTO_PAUSED = 'PAUSED (auto: session ended without DONE)' + + +def clean_in_git(ctx, name): + """True when git tracks the journal and it has no uncommitted change, so a hook edit would create one.""" + p = ctx.jpath(name) + return bool(git('ls-files', '--', p, cwd=ctx.main)) and not git('status', '--porcelain', '--', p, cwd=ctx.main) + + +def settle_stamps(ctx, name, own=None, own_status=None, pending_status=False): + """Write the header lines ended sessions left pending for this journal, oldest first, then this session's own. + A pending PAUSED is applied only by `sweep` (pending_status) and only from the latest ended session: a later + session that wrote the journal has superseded an earlier pause.""" + pend = sorted((l for l in ctx.leases() if l.get('journal') == name and l.get('stamp_pending') + and l.get('sid') != (own or {}).get('sid')), key=lambda l: l.get('ended') or 0) + for i, l in enumerate(pend): + last = i == len(pend) - 1 and own is None + stamp_session_line(ctx, name, l, status=AUTO_PAUSED if pending_status and last and + l.get('stamp_pending') == 'pause' else None) + with ctx.lock(): + cur = ctx.lease(l['sid']) or l + cur.pop('stamp_pending', None) + ctx.save_lease(cur, touch=False) + if own is not None: + stamp_session_line(ctx, name, own, status=own_status) + return len(pend) + + def on_session_end(ctx, inp): reason = inp.get('reason') or 'other' with ctx.lock(): @@ -1163,12 +1197,22 @@ def on_session_end(ctx, inp): lease['ended'] = time.time() lease['end_reason'] = reason ctx.save_lease(lease) + deferred = False if lease.get('journal') and lease['role'] != 'worker' and not ctx.owners(lease['journal']): # /clear starts a new session in the same window, which usually carries on: record it, keep the status. pause = reason != 'clear' and os.environ.get('JOURNAL_PAUSE_ON_END', '1') not in ('0', 'false', 'no') - stamp_session_line(ctx, lease['journal'], lease, - status='PAUSED (auto: session ended without DONE)' if pause else None) - ctx.trace(ev='SessionEnd', sid=lease['sid'], journal=lease.get('journal'), reason=reason) + if clean_in_git(ctx, lease['journal']): + # Everything was committed. Editing the journal now would leave a change nobody asked for after the + # agent has gone, so the header lines wait for the next session that changes the journal anyway (its + # own SessionEnd), or for `sweep`. Candidates show the pause meanwhile. + with ctx.lock(): + cur = ctx.lease(lease['sid']) or lease + cur['stamp_pending'] = 'pause' if pause else 'record' + ctx.save_lease(cur, touch=False) + deferred = True + else: + settle_stamps(ctx, lease['journal'], own=lease, own_status=AUTO_PAUSED if pause else None) + ctx.trace(ev='SessionEnd', sid=lease['sid'], journal=lease.get('journal'), reason=reason, deferred=deferred) return None @@ -1442,6 +1486,13 @@ def cmd_sweep(ctx, args): ctx.save_lease(l, touch=False) if l.get('journal') and not ctx.owners(l['journal']): stamp_session_line(ctx, l['journal'], l) + pending = sorted({l['journal'] for l in ctx.leases() if l.get('stamp_pending') and l.get('journal')}) + for n in pending: + if ctx.owners(n) or not os.path.exists(ctx.jpath(n)): + continue + changes.append(f'stamp {ctx.show(n)} (sessions that ended with everything committed)') + if not dry: + settle_stamps(ctx, n, pending_status=True) gdates = last_commit_dates(ctx) if by_git else {} if by_git and not gdates and git('ls-files', '--', ctx.jdir, cwd=ctx.main): print('sweep --by-git: git log over the journal directory returned nothing (timeout or error); refusing to ' diff --git a/spec/session-journal-spec.md b/spec/session-journal-spec.md index abba938..f4b52ce 100644 --- a/spec/session-journal-spec.md +++ b/spec/session-journal-spec.md @@ -66,6 +66,11 @@ A session that ends while its journal is still `ACTIVE` sets it to `PAUSED`, unl journal or another live session owns it. Ending with `/clear` records the session but leaves the status alone, because the same window usually carries on. +A hook never creates an uncommitted change in a journal that has none. When the journal is committed and unchanged +at session end, the `**Sessions:**` line and the `PAUSED` status wait in local state. Candidates show the journal as +`PAUSED (session ended)` meanwhile. The lines are written by the next session that ends after changing the journal +anyway, or by `sweep`. A later session's write supersedes a pending pause. + ## Binding: which journal is this session's A journal belongs to the sessions that write it. Picking "the newest journal" is not a selection rule; it is the diff --git a/templates/sessions-README.md b/templates/sessions-README.md index dc08625..56e97fc 100644 --- a/templates/sessions-README.md +++ b/templates/sessions-README.md @@ -35,7 +35,7 @@ Hooks bind each journal to the session that writes it (`.claude/hooks/session-jo - Unrelated work, or a journal past about 40 KB: create a new journal; link it with `**Continues:**`. - After context compaction: read the bound journal and its live plan before acting. - Keep `**Status:**` to one line; move state history to `## Change log`. -- Set `**Status:** DONE` when the work is finished. A session that ends with the journal still `ACTIVE` marks it `PAUSED`; the `**Sessions:**` line is maintained by the hook. +- Set `**Status:** DONE` when the work is finished. A session that ends with the journal still `ACTIVE` marks it `PAUSED`; the `**Sessions:**` line is maintained by the hook. If the journal was committed and unchanged, the hook leaves the file alone and writes those lines later, with the next change to it or on `sweep`. - Workers started with `JOURNAL_ROLE=worker` never write journals; their lead records the outcome. ## Sessions that need no journal diff --git a/tests/test_journal.py b/tests/test_journal.py index 5139ad5..0691062 100644 --- a/tests/test_journal.py +++ b/tests/test_journal.py @@ -549,6 +549,49 @@ def test_session_end_pauses_and_records_session_then_reconstruct(self): self.assertIn('2026-01-09-0900-flaky-upload-2.md', self.cli('reconstruct', '2026-01-08-1900-flaky-upload.md')) self.assertIn('flaky-upload', self.cli('find', 'flaky', 'upload')) + def test_a_committed_journal_is_not_edited_after_the_session_ends(self): + """Live Claude run: a session that committed everything left its journal modified by SessionEnd.""" + name = '2026-01-08-1920-clean.md' + t = Transcript(os.path.join(self.tmp, 'cl.jsonl')) + self.hook('SessionStart', 'c0c0c0c0-1', source='startup', transcript_path=t.path) + t.human('x') + self.write_journal('c0c0c0c0-1', t, name) + sh('git', 'add', '-A', cwd=self.repo) + sh('git', 'commit', '-qm', 'work and journal', cwd=self.repo) + before = open(os.path.join(self.jdir, name)).read() + self.hook('SessionEnd', 'c0c0c0c0-1', reason='other') + self.assertEqual(open(os.path.join(self.jdir, name)).read(), before) + self.assertEqual(sh('git', 'status', '--porcelain', cwd=self.repo), '') + self.assertIn(f'{name} [PAUSED (session ended)]', self.cli('candidates')) + # The next session that changes the journal anyway carries the pending line when it ends. + t2 = Transcript(os.path.join(self.tmp, 'cl2.jsonl')) + self.hook('SessionStart', 'c1c1c1c1-2', source='startup', transcript_path=t2.path) + t2.human('continue') + self.write_journal('c1c1c1c1-2', t2, name, text=before.replace('- working', '- working further')) + self.assertNotIn('PAUSED (session ended)', self.cli('candidates'), 'a later write supersedes the pending pause') + self.hook('SessionEnd', 'c1c1c1c1-2', reason='other') + text = open(os.path.join(self.jdir, name)).read() + self.assertIn('c0c0c0c0', text) + self.assertIn('c1c1c1c1', text) + self.assertIn('**Status:** PAUSED', text) + self.assertNotIn('stamp_pending', json.dumps(self.lease('c0c0c0c0-1'))) + + def test_sweep_applies_pending_stamps(self): + name = '2026-01-08-1930-swept.md' + t = Transcript(os.path.join(self.tmp, 'sw.jsonl')) + self.hook('SessionStart', 'c2c2c2c2-1', source='startup', transcript_path=t.path) + t.human('x') + self.write_journal('c2c2c2c2-1', t, name) + sh('git', 'add', '-A', cwd=self.repo) + sh('git', 'commit', '-qm', 'j', cwd=self.repo) + self.hook('SessionEnd', 'c2c2c2c2-1', reason='other') + self.assertIn(f'would stamp {self.jrel}/{name}', self.cli('sweep', '--dry-run')) + self.assertIn('**Status:** ACTIVE', open(os.path.join(self.jdir, name)).read()) + self.cli('sweep') + text = open(os.path.join(self.jdir, name)).read() + self.assertIn('**Status:** PAUSED', text) + self.assertIn('c2c2c2c2', text) + def test_pause_on_end_can_be_turned_off(self): t = Transcript(os.path.join(self.tmp, 'pe.jsonl')) self.hook('SessionStart', '5c5c5c5c-1', source='startup', transcript_path=t.path) From a9b002734fff1a401b17a78824558abc44c50966 Mon Sep 17 00:00:00 2001 From: dctmfoo Date: Wed, 21 Oct 2026 23:57:09 +0530 Subject: [PATCH 14/14] ci: allowlist the synthetic credential fixtures in 1.1.0 test history The full-history gitleaks job flags six fake values from the retired secrets-guard.bats and an earlier doctor.bats. They exist to prove the secrets check fires; the current tests build such strings at runtime. --- .gitleaksignore | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .gitleaksignore diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..ff4d477 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,8 @@ +# Synthetic credential fixtures in 1.1.0 test history (tests/secrets-guard.bats, tests/doctor.bats). +# They exist to prove the secrets check fires; none is a real credential. +a9a5e9b5571efc042c3bb608be2e4c72516420ac:tests/secrets-guard.bats:generic-api-key:123 +a9a5e9b5571efc042c3bb608be2e4c72516420ac:tests/secrets-guard.bats:generic-api-key:124 +a9a5e9b5571efc042c3bb608be2e4c72516420ac:tests/secrets-guard.bats:generic-api-key:125 +a9a5e9b5571efc042c3bb608be2e4c72516420ac:tests/secrets-guard.bats:generic-api-key:128 +a9a5e9b5571efc042c3bb608be2e4c72516420ac:tests/secrets-guard.bats:generic-api-key:131 +a9e4e2a3a55261d6b4d234eb80f12ebcceacabc7:tests/doctor.bats:generic-api-key:65