Skip to content

Commit a71081d

Browse files
endolinbotclaude
andcommitted
transcript-capture: durable fleet transcript archive + fleet-wide deletion disable
Implements designs/transcript-journal-capture.md § Builder spec (items 1–8). Everything is INERT until a maintainer arms a transcripts remote (config/transcripts-remote on journal2, absent today): unarmed, every host still disables Claude Code's deletion and spools its finished transcripts locally, losing nothing — only the push is gated. - common.sh: GARDEN_TRANSCRIPTS_BRANCH/_SPOOL/_IDLE_SECS knobs + transcript_spool helper (offline-safe gzip-into-spool, never fails its caller). - handlers/gardener-claude.sh: spool the finished transcript BEFORE the existing rm -f (the false-resume rm and its rationale stay verbatim). - transcript-capture.sh: the per-tick sweep — reconcile cleanupPeriodDays=36500 first/unconditionally (jq read-modify-write, foreign keys preserved), read the armed remote from the journal (inert if absent), ensure a partial+sparse transcripts2 clone (orphan-init when the branch is absent), drain the spool + sweep idle/changed ~/.claude/projects sessions through redact_stream then gzip -n, commit + verified CAS push (fetch/reset/reapply), clear spool + update ledger only after a verified push. Runs on EVERY host (no is-main-host gate). - set-transcripts-remote.sh: CAS-writes config/transcripts-remote (the arming act). - garden-transcript-capture.{service,timer}: hourly at :20, self-heal-run wrapped, no is-main-host ExecCondition; auto-enabled by install-units. - garden launcher: seed cleanupPeriodDays=36500 into new instances' settings.json. - test/transcript-capture-test.sh: 20 assertions, all green — inert-when-unarmed, settings reconcile (absent file + foreign keys), spool drain, redaction, idle gating, changed-session re-capture, and CAS retry against a racing peer commit. - context/operations/transcripts.md: operator page (arming, browsing, rotation). Arming (create the private repo, grant push, run set-transcripts-remote.sh, post the role/liaison notice) is the maintainer's act, after this lands. Bring-up surfacing + the liaison broadcast-reader are the serial follow-up child build-liaison-broadcast-reader; starting.md and roles/liaison/AGENT.md are deliberately untouched here to avoid conflicting with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2568be4 commit a71081d

9 files changed

Lines changed: 900 additions & 4 deletions

File tree

context/operations/transcripts.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Transcript durability (the archive)
2+
3+
How the garden keeps its own session transcripts instead of letting Claude Code
4+
delete them, and how to browse one later. Two postures ship together:
5+
6+
- **Deletion is disabled fleet-wide.** Claude Code deletes session transcripts
7+
after `cleanupPeriodDays` (default 30). The garden sets `cleanupPeriodDays:
8+
36500` ("effectively never") — the `garden` launcher seeds it into every new
9+
instance's `~/.claude/settings.json`, and `transcript-capture.sh` reconciles the
10+
same key on every existing host each tick (settings.json is gitignored, so it
11+
cannot propagate via git — both prongs are required). This half needs **no
12+
arming**; it is on the moment this build deploys.
13+
- **Transcripts are archived** into a dedicated `transcripts2` orphan branch on a
14+
configurable remote, gzip-compressed and lightly redacted, keyed by host. This
15+
half is **inert until you arm a transcripts remote** — until then every host
16+
still disables deletion and **spools** its finished transcripts locally
17+
(`$GARDEN_STATE/transcripts/spool`), losing nothing; only the push is gated.
18+
19+
Design and rationale: [`designs/transcript-journal-capture.md`](../../designs/transcript-journal-capture.md).
20+
21+
## Arming the archive (maintainer-only, deliberate)
22+
23+
Transcripts are the fleet's raw working memory — tokens that scrolled through a
24+
shell, maintainer email, private reasoning. Publishing them is not a default any
25+
agent picks, so the remote is per-instance journal config and arming is your
26+
call.
27+
28+
1. **Create a remote to hold the branch.** A **dedicated private repo** is
29+
recommended (e.g. `kriskowal/garden-transcripts`); grant the bot push access.
30+
Using the garden's own origin is possible but **it is public** — that would
31+
publish the fleet's transcripts to the world.
32+
2. **Point the fleet at it:**
33+
34+
```sh
35+
scripts/jobs/set-transcripts-remote.sh git@github.com:kriskowal/garden-transcripts.git
36+
```
37+
38+
This CAS-writes `config/transcripts-remote` on `journal2`. The next
39+
`garden-transcript-capture` tick on each host (hourly, at :20) reads it, drains
40+
its spool, and pushes. If the `transcripts2` branch does not exist on the
41+
remote yet, the first push creates it.
42+
3. **Record it** with a journal `message` entry (the arming is a standing state
43+
change), and — because local `~/.claude` disk no longer self-prunes — post a
44+
fleet notice so every liaison surfaces the disk-posture change:
45+
46+
```sh
47+
scripts/jobs/send-msg.sh role/liaison "transcript durability is armed; local ~/.claude no longer self-prunes — size disk accordingly"
48+
```
49+
50+
Left unarmed, nothing breaks: deletion stays disabled and transcripts spool
51+
locally until you arm a remote (spooled entries drain on the first armed tick).
52+
53+
## Where transcripts land
54+
55+
```
56+
transcripts/<GARDEN>/<encoded-cwd>/<session-id>.jsonl.gz # one per captured session
57+
index/<GARDEN>.tsv # one appended row per capture
58+
```
59+
60+
`<GARDEN>` is the host identity, so hosts never write each other's paths and
61+
concurrent pushes conflict only at the CAS. Each `index/<GARDEN>.tsv` row is
62+
`captured_at session_id job_base(or -) encoded_cwd raw_bytes gz_bytes raw_mtime`.
63+
The encoded cwd of a gardener job contains `gardener-wt-<base>`, so the job base
64+
is recoverable from the path alone.
65+
66+
## Browsing a transcript
67+
68+
Clone the transcripts remote's branch (or reuse a host's capture clone at
69+
`$GARDEN_STATE/transcripts/clone`):
70+
71+
```sh
72+
git clone --branch transcripts2 <transcripts-remote> /tmp/transcripts
73+
# find a job's transcript across all hosts by its base:
74+
ls /tmp/transcripts/transcripts/*/*gardener-wt-build-thing*/
75+
# read one:
76+
git -C /tmp/transcripts show transcripts2:transcripts/<host>/<dir>/<sid>.jsonl.gz | gunzip | less
77+
```
78+
79+
Secrets in known shapes (GitHub tokens, `sk-ant-…`, `Authorization: Bearer …`)
80+
are masked with `…REDACTED` before storage — defense in depth behind the
81+
private-remote scoping, which is the primary guarantee. No redaction list is
82+
complete; keep the remote private.
83+
84+
## Rotation (the escape hatch, if the branch ever grows too large)
85+
86+
With capture-on-completion each transcript is committed about once, so history
87+
overhead stays near the tree size and this is unlikely to bite for years. No
88+
pruning is built. If the branch ever needs shrinking, **rotate, don't rewrite**:
89+
90+
```sh
91+
git -C <clone> tag transcripts2-archive-<year> transcripts2
92+
git -C <clone> push origin transcripts2-archive-<year>
93+
# then start a fresh orphan transcripts2 (delete the branch on the remote and let
94+
# the next capture tick re-init it), keeping the tagged snapshot for cold storage.
95+
```
96+
97+
File an issue when the corpus first warrants it rather than pre-optimizing.
98+
99+
## Knobs
100+
101+
| Env | Default | Meaning |
102+
| --- | --- | --- |
103+
| `GARDEN_TRANSCRIPTS_BRANCH` | `transcripts2` | the orphan branch transcripts live on |
104+
| `GARDEN_TRANSCRIPTS_SPOOL` | `$GARDEN_STATE/transcripts/spool` | per-host completion-hook staging dir |
105+
| `GARDEN_TRANSCRIPT_IDLE_SECS` | `21600` (6h) | a session is swept only once idle this long |
106+
107+
The optional local janitor (delete originals already archived) and a
108+
`find-transcript.sh` wrapper are deferred follow-ons, not part of this build.

garden

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,14 @@ cmd_reset() {
108108
# only when absent (a hand-edited file is preserved). The container's home IS
109109
# the bind-mounted repo root, so this one file covers both the user-level
110110
# (~/.claude/settings.json) and project-level (<repo>/.claude/settings.json)
111-
# scopes at once. Its sole content is a SessionStart hook running the container
112-
# guard, making the guard automatic for every session that starts in the garden
113-
# root — INCLUDING a host-side session, which is exactly where the guard must
114-
# fire. It deliberately does NOT set a permission defaultMode: the settings file
111+
# scopes at once. It carries a SessionStart hook running the container guard,
112+
# making the guard automatic for every session that starts in the garden root —
113+
# INCLUDING a host-side session, which is exactly where the guard must fire — plus
114+
# `cleanupPeriodDays: 36500` so Claude Code's 30-day transcript deletion is
115+
# disabled from the first session (the transcript archive keeps the garden's
116+
# record; designs/transcript-journal-capture.md). transcript-capture.sh reconciles
117+
# the same key on existing hosts, since this file is gitignored and cannot
118+
# propagate via git. It deliberately does NOT set a permission defaultMode: the settings file
115119
# is visible host-side too, so a settings-level bypass would silently make
116120
# host-side sessions permissionless. Auto mode rides the launch flag instead
117121
# (see the exec in cmd_enter), which applies only to the in-container session.
@@ -121,6 +125,7 @@ seed_claude_settings() {
121125
mkdir -p "${SCRIPT_DIR}/.claude"
122126
cat > "$settings" <<'JSON'
123127
{
128+
"cleanupPeriodDays": 36500,
124129
"hooks": {
125130
"SessionStart": [
126131
{

scripts/jobs/common.sh

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,29 @@ export GARDEN
126126
# the old marker, is never stranded. Remove once no host carries a NOPE marker.
127127
: "${GARDEN_KILLSWITCH:=$GARDEN_STATE/NOPE}"
128128

129+
# --- transcript capture (designs/transcript-journal-capture.md) ---------------
130+
#
131+
# The garden captures every host's finished session transcripts into a dedicated
132+
# `transcripts2` orphan branch on a configurable transcripts remote, so the
133+
# journal holds the garden's transcript. All three knobs are overridable so the
134+
# test harness can point the same code at a throwaway spool/remote.
135+
#
136+
# GARDEN_TRANSCRIPTS_BRANCH — the orphan branch transcripts live on, mirroring the
137+
# journal2 pattern; it is never merged with main2/journal2 and only the capture
138+
# service and a browsing human ever fetch it.
139+
# GARDEN_TRANSCRIPTS_SPOOL — a per-host staging dir under $GARDEN_STATE (outside
140+
# any reset-prone worktree) where the gardener completion hook drops a gzip copy
141+
# of a finishing job's transcript BEFORE the handler's `rm -f` retires it (the
142+
# false-resume hazard is real, so the rm stays). The hourly capture timer drains
143+
# the spool. Spooling happens whether or not a remote is armed, so nothing is
144+
# lost while the archive is unconfigured.
145+
# GARDEN_TRANSCRIPT_IDLE_SECS — the sweep captures a `~/.claude/projects` session
146+
# only once its mtime is older than this (default six hours), so a live,
147+
# still-growing session is not captured over and over.
148+
: "${GARDEN_TRANSCRIPTS_BRANCH:=transcripts2}"
149+
: "${GARDEN_TRANSCRIPTS_SPOOL:=$GARDEN_STATE/transcripts/spool}"
150+
: "${GARDEN_TRANSCRIPT_IDLE_SECS:=21600}"
151+
129152
# --- bounded git network operations (the stuck-fetch hardening) --------------
130153
#
131154
# A journal fetch should finish in well under a second, but git has NO default
@@ -713,6 +736,47 @@ kill_stale_worktree_handlers() {
713736
return 0
714737
}
715738

739+
# transcript_spool <jsonl-path> [<job-base>] — stage a finishing session's
740+
# transcript into the capture spool so the hourly sweep (transcript-capture.sh)
741+
# can archive it even though the gardener completion hook is about to `rm` the
742+
# original (the false-resume hazard the rm exists to prevent). gzip-copies
743+
# <jsonl-path> to
744+
# $GARDEN_TRANSCRIPTS_SPOOL/<encoded-cwd>/<session-id>.jsonl.gz
745+
# and appends a pending index row (tab-separated: spooled_at, session_id,
746+
# job_base-or-'-', encoded_cwd) to $GARDEN_TRANSCRIPTS_SPOOL/pending.tsv. The
747+
# <encoded-cwd> is the parent directory NAME of the jsonl (Claude Code's project
748+
# dir), which already carries the `gardener-wt-<base>` pattern the sweep can
749+
# back-derive a base from. Does NO network work and NEVER fails its caller —
750+
# every path logs and returns 0 — so the completion path stays fast and
751+
# offline-safe and the spool survives under $GARDEN_STATE until the timer drains
752+
# it. A missing/empty source is a silent no-op (returns 0): the hook calls this
753+
# for BOTH candidate encodings and only one exists. Redaction is NOT applied here
754+
# (the spool is un-redacted, gzipped raw); the sweep redacts on drain, so
755+
# redaction lives in exactly one place.
756+
transcript_spool() {
757+
local src="${1:-}" base="${2:--}"
758+
[ -n "$src" ] && [ -f "$src" ] || return 0
759+
local encoded_cwd sid spool_dir dest now
760+
encoded_cwd="$(basename "$(dirname "$src")")"
761+
sid="$(basename "$src" .jsonl)"
762+
spool_dir="${GARDEN_TRANSCRIPTS_SPOOL}/${encoded_cwd}"
763+
if ! mkdir -p "$spool_dir" 2>/dev/null; then
764+
log "WARN: transcript_spool: cannot create spool dir $spool_dir; not spooling $sid"
765+
return 0
766+
fi
767+
dest="$spool_dir/$sid.jsonl.gz"
768+
if ! gzip -nc -- "$src" > "$dest" 2>/dev/null; then
769+
log "WARN: transcript_spool: gzip of $src failed; not spooling $sid"
770+
rm -f "$dest" 2>/dev/null || true
771+
return 0
772+
fi
773+
now="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo -)"
774+
printf '%s\t%s\t%s\t%s\n' "$now" "$sid" "${base:--}" "$encoded_cwd" \
775+
>> "${GARDEN_TRANSCRIPTS_SPOOL}/pending.tsv" 2>/dev/null \
776+
|| log "WARN: transcript_spool: could not append pending index row for $sid"
777+
return 0
778+
}
779+
716780
bot_name() { git -C "$GARDEN_ROOT" config --get user.name 2>/dev/null || echo garden-bot; }
717781
bot_email() { git -C "$GARDEN_ROOT" config --get user.email 2>/dev/null || echo garden-bot@localhost; }
718782

scripts/jobs/handlers/gardener-claude.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,13 @@ fi
305305
# GARDEN_SCRATCH_GC_AGE hours of quiescence.
306306
if [ -n "${GARDEN_COMPLETION_SENTINEL:-}" ] && [ -e "$GARDEN_COMPLETION_SENTINEL" ]; then
307307
scratch_cleanup "$worktree"
308+
# Spool the finished transcript into the capture archive BEFORE the rm below
309+
# retires it (designs/transcript-journal-capture.md). transcript_spool is
310+
# offline-safe and never fails the handler; it gzip-copies whichever candidate
311+
# encoding exists into $GARDEN_TRANSCRIPTS_SPOOL and the hourly capture timer
312+
# drains it. Carrying $base records the job identity authoritatively.
313+
transcript_spool "$proj_dir/$session_id.jsonl" "$base"
314+
transcript_spool "$proj_dir_alt/$session_id.jsonl" "$base"
308315
# Retire the session transcript too. The session id is DETERMINISTIC from the
309316
# base, so a later re-post of a drained base would otherwise find this
310317
# finished session and --resume it: a model whose history ends "job finished,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/bin/bash
2+
# set-transcripts-remote.sh — arm the transcripts archive by declaring the remote
3+
# that carries the `transcripts2` orphan branch, in the journal (CAS).
4+
#
5+
# Usage: set-transcripts-remote.sh <git-url>
6+
# e.g. set-transcripts-remote.sh git@github.com:kriskowal/garden-transcripts.git
7+
#
8+
# Writes config/transcripts-remote on the journal. transcript-capture.sh reads it
9+
# at runtime and is INERT until it exists: every host still disables Claude Code's
10+
# deletion and SPOOLS its finished transcripts locally, but pushes nowhere until a
11+
# remote is configured. Writing this file is therefore the deliberate ARMING act,
12+
# and — because transcripts are the fleet's raw working memory — a safety-weighted
13+
# one the maintainer performs (a private repo is recommended; the garden's own
14+
# origin is public). Record it with a journal `message` entry when you arm it.
15+
# See designs/transcript-journal-capture.md and context/operations/transcripts.md.
16+
#
17+
# Overwrites one file, so a rejected push just re-syncs and retries. Modeled on
18+
# set-garden-repo.sh.
19+
20+
set -euo pipefail
21+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22+
# shellcheck source=common.sh
23+
source "$HERE/common.sh"
24+
GARDEN_TAG="set-transcripts-remote"
25+
26+
url="${1:?usage: set-transcripts-remote.sh <git-url>}"
27+
# Accept the git URL shapes we actually push over: scp-like ssh
28+
# (git@host:owner/repo.git), ssh:// , https:// , and a local path (for tests).
29+
case "$url" in
30+
*[[:space:]]*) die "illegal transcripts remote '$url' (contains whitespace)";;
31+
git@*:*|ssh://*|https://*|http://*|/*|file://*) : ;;
32+
*) die "illegal transcripts remote '$url' (expected git@host:owner/repo.git, ssh://…, https://…, or an absolute path)";;
33+
esac
34+
35+
DIR="${GARDEN_PRODUCER_CLONE:-$GARDEN_STATE/producer/journal}"
36+
ensure_clone "$DIR"
37+
38+
for attempt in $(seq 1 50); do
39+
sync_clone "$DIR"
40+
mkdir -p "$DIR/config"
41+
printf '%s\n' "$url" > "$DIR/config/transcripts-remote"
42+
git -C "$DIR" add "config/transcripts-remote"
43+
# Capture the return with `|| rc=$?` (NOT an `if`): a false `if` with no `else`
44+
# has exit status 0, which would swallow commit_and_push's rc=2 "nothing to
45+
# commit" (the idempotent re-run) and loop forever.
46+
rc=0; commit_and_push "$DIR" "config: transcripts-remote=$url" || rc=$?
47+
[ "$rc" -eq 0 ] && { log "set transcripts-remote=$url"; exit 0; }
48+
[ "$rc" -eq 2 ] && { log "transcripts-remote already $url"; exit 0; }
49+
log "set transcripts-remote lost a push race (attempt $attempt); re-syncing"
50+
backoff "$attempt"
51+
done
52+
die "could not set transcripts-remote after retries"

0 commit comments

Comments
 (0)