@trailkeep/cli 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/trailkeep ADDED
@@ -0,0 +1,1456 @@
1
+ #!/bin/sh
2
+ # trailkeep: two-layer context (global knowledge + per-branch task files) for
3
+ # Claude Code, Codex CLI and GitHub Copilot CLI. POSIX sh + git, no other deps.
4
+ # https://github.com/msiric/trailkeep
5
+
6
+ set -u
7
+
8
+ VERSION="0.3.1"
9
+ PKG="@trailkeep/cli"
10
+ MARKER="managed by trailkeep"
11
+
12
+ # ---------------------------------------------------------------- paths ----
13
+
14
+ resolve_self() {
15
+ # Portable "readlink -f" for this script (macOS ships no -f before 12.3).
16
+ p=$0
17
+ while [ -L "$p" ]; do
18
+ d=$(cd "$(dirname "$p")" && pwd -P)
19
+ l=$(readlink "$p")
20
+ case $l in /*) p=$l ;; *) p=$d/$l ;; esac
21
+ done
22
+ d=$(cd "$(dirname "$p")" && pwd -P)
23
+ printf '%s/%s\n' "$d" "$(basename "$p")"
24
+ }
25
+
26
+ SELF=$(resolve_self)
27
+ ROOT=$(cd "$(dirname "$SELF")/.." && pwd -P)
28
+
29
+ TRAILKEEP_HOME=${TRAILKEEP_HOME:-$HOME/.agents}
30
+ PERSONAL=$TRAILKEEP_HOME/AGENTS.md
31
+ REPOS=$TRAILKEEP_HOME/repos
32
+ SKILLS_DIR=$HOME/.agents/skills # read by Codex and Copilot
33
+ BIN_DIR=${TRAILKEEP_BIN_DIR:-$HOME/.local/bin}
34
+ CLAUDE_DIR=${CLAUDE_CONFIG_DIR:-$HOME/.claude}
35
+ CODEX_DIR=${CODEX_HOME:-$HOME/.codex}
36
+ COPILOT_DIR=${COPILOT_HOME:-$HOME/.copilot}
37
+ # install copies the tool here and points every hook, link and the plugin at
38
+ # it, so moving the clone, npm upgrades or switching Node versions never break
39
+ # hooks (or Codex's per-hook trust).
40
+ RUNTIME=$TRAILKEEP_HOME/trailkeep
41
+ RT_BIN=$RUNTIME/bin/trailkeep
42
+ MAX_CHARS=${TRAILKEEP_MAX_CHARS:-9000} # Claude caps hook text at 10,000 chars
43
+ case $MAX_CHARS in ''|*[!0-9]*) MAX_CHARS=9000 ;; esac
44
+ [ "$MAX_CHARS" -gt 9500 ] && MAX_CHARS=9500
45
+
46
+ # --------------------------------------------------------------- output ----
47
+
48
+ if [ -t 1 ]; then
49
+ C_OK=$(printf '\033[32m'); C_WARN=$(printf '\033[33m'); C_FAIL=$(printf '\033[31m'); C_DIM=$(printf '\033[2m'); C_OFF=$(printf '\033[0m')
50
+ else
51
+ C_OK=; C_WARN=; C_FAIL=; C_DIM=; C_OFF=
52
+ fi
53
+
54
+ say() { printf '%s\n' "$*"; }
55
+ ok() { printf '%s ok%s %s\n' "$C_OK" "$C_OFF" "$*"; }
56
+ warn() { printf '%s warn%s %s\n' "$C_WARN" "$C_OFF" "$*"; WARNS=$((WARNS + 1)); }
57
+ fail() { printf '%s fail%s %s\n' "$C_FAIL" "$C_OFF" "$*"; FAILS=$((FAILS + 1)); }
58
+ info() { printf '%s info%s %s\n' "$C_DIM" "$C_OFF" "$*"; }
59
+ hint() { printf ' %s-> %s%s\n' "$C_DIM" "$*" "$C_OFF"; }
60
+ die() { printf 'trailkeep: %s\n' "$*" >&2; exit 1; }
61
+ WARNS=0; FAILS=0
62
+
63
+ # Pretty-print a path with ~ for $HOME.
64
+ tilde() { case $1 in "$HOME"/*) printf '~/%s\n' "${1#"$HOME"/}" ;; *) printf '%s\n' "$1" ;; esac; }
65
+
66
+ has() { command -v "$1" >/dev/null 2>&1; }
67
+
68
+ # --------------------------------------------------------- dry-run fs ops ----
69
+
70
+ DRY=0
71
+ act() { # describe + perform unless dry-run; failures are counted
72
+ printf ' %s\n' "$1"
73
+ [ "$DRY" -eq 1 ] && return 0
74
+ shift
75
+ if ! "$@"; then
76
+ printf '%s fail%s the step above did not complete\n' "$C_FAIL" "$C_OFF"
77
+ FAILS=$((FAILS + 1))
78
+ return 1
79
+ fi
80
+ }
81
+
82
+ write_file() { # write_file PATH CONTENT
83
+ mkdir -p "$(dirname "$1")" && printf '%s\n' "$2" > "$1"
84
+ }
85
+
86
+ make_link() { mkdir -p "$(dirname "$1")" && ln -s "$2" "$1"; }
87
+ move_file() { mkdir -p "$(dirname "$2")" && mv "$1" "$2"; }
88
+ quiet() { "$@" >/dev/null; }
89
+
90
+ # Put the trailkeep block at the top of FILE (replacing any old block).
91
+ put_block() { # FILE BLOCK
92
+ mkdir -p "$(dirname "$1")" || return 1
93
+ tmp=$(mktemp) || return 1
94
+ [ -f "$1" ] && strip_block "$1" > "$tmp"
95
+ { printf '%s\n' "$2"; if [ -s "$tmp" ]; then printf '\n'; awk 'NF { p = 1 } p' "$tmp"; fi; } > "$1"
96
+ st=$?; rm -f "$tmp"; return $st
97
+ }
98
+
99
+ drop_block() { # FILE
100
+ tmp=$(mktemp)
101
+ strip_block "$1" | awk 'NF { p = 1 } p' > "$tmp"
102
+ cat "$tmp" > "$1"
103
+ rm -f "$tmp"
104
+ }
105
+
106
+ # Move the user's own lines from ~/.claude/CLAUDE.md into the personal file.
107
+ adopt_claude_md() { # CLAUDE_MD PERSONAL
108
+ mkdir -p "$(dirname "$2")"
109
+ strip_block "$1" | awk 'NF { p = 1 } p' > "$2"
110
+ : > "$1"
111
+ }
112
+
113
+ backup() { # copy a file (following symlinks) into the backup dir before changing it
114
+ [ -f "$1" ] || return 0
115
+ [ "$DRY" -eq 1 ] && return 0
116
+ mkdir -p "$BACKUP_DIR" && cp "$1" "$BACKUP_DIR/$(printf '%s' "$1" | sed 's#/#_#g')"
117
+ }
118
+ BACKUP_DIR=$TRAILKEEP_HOME/backups/$(date +%Y%m%d-%H%M%S)
119
+
120
+ # ------------------------------------------------------------ git helpers ----
121
+
122
+ in_repo() { git rev-parse --is-inside-work-tree >/dev/null 2>&1; }
123
+
124
+ # The main checkout (shared by all worktrees of the repo).
125
+ main_checkout() {
126
+ cd_=$(git rev-parse --git-common-dir 2>/dev/null) || return 1
127
+ cd_=$(cd "$cd_" && pwd -P) || return 1
128
+ case $cd_ in */.git) dirname "$cd_" ;; *) printf '%s\n' "$cd_" ;; esac
129
+ }
130
+
131
+ # Stable key per repository: from the origin URL when there is one, so every
132
+ # clone and worktree of the same repo shares notes and efforts.
133
+ repo_key() {
134
+ url=$(git config --get remote.origin.url 2>/dev/null || true)
135
+ if [ -n "$url" ]; then
136
+ printf '%s\n' "$url" | sed \
137
+ -e 's#^[A-Za-z][A-Za-z0-9+.-]*://##' \
138
+ -e 's#^[^@/]*@##' \
139
+ -e 's#:#/#' \
140
+ -e 's#/*$##' -e 's#\.git$##' \
141
+ | tr 'A-Z' 'a-z' | sed -e 's#/#__#g' -e 's#[^a-z0-9._-]#_#g'
142
+ else
143
+ mc=$(main_checkout) || return 1
144
+ sum=$(printf '%s' "$mc" | cksum | cut -d' ' -f1)
145
+ printf 'local__%s-%s\n' "$(basename "$mc" | tr 'A-Z' 'a-z' | sed 's#[^a-z0-9._-]#_#g')" "$sum"
146
+ fi
147
+ }
148
+
149
+ current_branch() {
150
+ if [ -n "${TRAILKEEP_EFFORT:-}" ]; then printf '%s\n' "$TRAILKEEP_EFFORT"; return 0; fi
151
+ git symbolic-ref --quiet --short HEAD 2>/dev/null
152
+ }
153
+
154
+ branch_file_id() { printf '%s\n' "$1" | sed 's#/#__#g'; }
155
+
156
+ repo_dir() { printf '%s/%s\n' "$REPOS" "$1"; }
157
+ notes_path() { printf '%s/%s/NOTES.md\n' "$REPOS" "$1"; }
158
+ worktree_top() { git rev-parse --show-toplevel 2>/dev/null; }
159
+ # Efforts live in the worktree (inside every agent's writable workspace) under
160
+ # a self-ignoring .trailkeep/ folder, so the repo's own files never change.
161
+ effort_dir() { printf '%s/.trailkeep\n' "$(worktree_top)"; }
162
+ effort_path() { printf '%s/%s.md\n' "$(effort_dir)" "$(branch_file_id "$2")"; }
163
+
164
+ ensure_effort_dir() {
165
+ d=$(effort_dir)
166
+ [ -e "$d" ] && [ ! -d "$d" ] && die "$d exists and is not a directory"
167
+ mkdir -p "$d" || die "could not create $d"
168
+ [ -f "$d/.gitignore" ] || printf '# trailkeep: keep this folder out of git\n*\n' > "$d/.gitignore"
169
+ }
170
+
171
+ need_repo() {
172
+ in_repo || die "not inside a git repository"
173
+ KEY=$(repo_key) || die "could not identify this repository"
174
+ }
175
+
176
+ need_branch() {
177
+ BRANCH=$(current_branch) || die "detached HEAD: check out a branch or set TRAILKEEP_EFFORT=<name>"
178
+ [ -n "$BRANCH" ] || die "detached HEAD: check out a branch or set TRAILKEEP_EFFORT=<name>"
179
+ }
180
+
181
+ remember_repo() { # best effort: note this worktree so `list` can find it (may be sandboxed)
182
+ d=$(repo_dir "$KEY"); w=$(worktree_top)
183
+ { mkdir -p "$d" && touch "$d/worktrees" && { grep -qxF "$w" "$d/worktrees" || printf '%s\n' "$w" >> "$d/worktrees"; }; } 2>/dev/null || true
184
+ }
185
+
186
+ # ------------------------------------------------------------------ text ----
187
+
188
+ json_escape() {
189
+ tr -d '\000-\010\013\014\016-\037' | awk 'BEGIN { ORS = "" }
190
+ { gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t"); gsub(/\r/, "\\r")
191
+ if (NR > 1) printf "\\n"; printf "%s", $0 }'
192
+ }
193
+
194
+ # Extract a top-level string field from a small JSON object on stdin.
195
+ json_field() {
196
+ sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1 | sed -e 's#\\/#/#g' -e 's#\\\\#\\#g'
197
+ }
198
+
199
+ chars() { wc -c < "$1" | tr -d ' '; }
200
+ lines() { wc -l < "$1" | tr -d ' '; }
201
+
202
+ # Lines of a "## Heading" section.
203
+ section() { # section FILE HEADING
204
+ awk -v h="## $2" '$0 == h { on = 1; next } /^## / { on = 0 } on' "$1"
205
+ }
206
+
207
+ today() { date +%Y-%m-%d; }
208
+
209
+ # --------------------------------------------------------------- context ----
210
+ # Called by each agent's SessionStart hook. Prints nothing when there is
211
+ # nothing to add, so repos without notes or efforts cost zero tokens.
212
+
213
+ piece() { # piece LABEL PATH BUDGET -> prints the block, or a pointer if too big
214
+ n=$(chars "$2")
215
+ if [ "$n" -le "$3" ]; then
216
+ printf '%s (%s):\n' "$1" "$2"
217
+ cat "$2"
218
+ else
219
+ printf '%s is at %s (%s chars, over the trailkeep budget). Read that file before starting work.\n' "$1" "$2" "$n"
220
+ fi
221
+ }
222
+
223
+ default_branch() {
224
+ d=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null) && { printf '%s\n' "${d#origin/}"; return 0; }
225
+ for d in main master trunk develop; do git show-ref --verify --quiet "refs/heads/$d" && { printf '%s\n' "$d"; return 0; }; done
226
+ return 1
227
+ }
228
+
229
+ build_context() {
230
+ in_repo || return 0
231
+ KEY=$(repo_key) || return 0
232
+ notes=$(notes_path "$KEY")
233
+ effort=
234
+ if b=$(current_branch) && [ -n "$b" ]; then effort=$(effort_path "$KEY" "$b"); fi
235
+
236
+ has_notes=0; has_effort=0
237
+ [ -s "$notes" ] && has_notes=1
238
+ [ -n "$effort" ] && [ -s "$effort" ] && has_effort=1
239
+
240
+ budget=$MAX_CHARS
241
+ if [ "$has_notes" -eq 1 ]; then
242
+ piece "trailkeep: private notes for this repository" "$notes" "$((budget / 3))"
243
+ budget=$((budget - $(chars "$notes")))
244
+ [ "$budget" -lt $((MAX_CHARS / 2)) ] && budget=$((MAX_CHARS / 2))
245
+ printf '\n'
246
+ fi
247
+ if [ "$has_effort" -eq 1 ]; then
248
+ printf 'trailkeep: this branch has an effort file. It is the source of truth for the current task. Keep it current as you work: tick Plan items, add one dated line under Progress before you stop, and record decisions and dead ends (start a line with "Dead end:" for approaches that failed).\n'
249
+ piece "trailkeep: effort file for branch $b" "$effort" "$budget"
250
+ printf '\n'
251
+ elif [ "${TRAILKEEP_HINT:-1}" != 0 ] && [ -n "$effort" ] && db=$(default_branch) && [ "$b" != "$db" ]; then
252
+ printf 'trailkeep: no effort file for branch %s. If this task spans more than one session, run `trailkeep new` and keep it current.\n' "$b"
253
+ fi
254
+ n=0
255
+ for f in "$(log_dir "$KEY")"/*.tsv; do [ -f "$f" ] && n=$((n + $(lines "$f"))); done
256
+ if [ "$n" -gt 0 ]; then
257
+ printf 'trailkeep: this repo has a decision log (%s entries). Before changing code with history, or to learn why something is the way it is, run `trailkeep log --path <file>` or `trailkeep log --grep <word>`; `trailkeep log show <id>` gives one entry in full.\n' "$n"
258
+ fi
259
+ }
260
+
261
+ cmd_context() {
262
+ target=text
263
+ while [ $# -gt 0 ]; do
264
+ case $1 in
265
+ --for) target=${2:-}; shift 2 ;;
266
+ --for=*) target=${1#--for=}; shift ;;
267
+ *) die "context: unknown option $1" ;;
268
+ esac
269
+ done
270
+ case $target in claude|codex|copilot|text) ;; *) die "context: --for must be claude, codex, copilot or text" ;; esac
271
+
272
+ input=
273
+ if [ "$target" != text ] && [ ! -t 0 ]; then input=$(cat); fi
274
+ if [ -n "$input" ]; then
275
+ src=$(printf '%s' "$input" | json_field source)
276
+ # Resumed sessions already contain the earlier injection.
277
+ [ "$src" = resume ] && exit 0
278
+ cwd=$(printf '%s' "$input" | json_field cwd)
279
+ if [ -n "$cwd" ] && [ -d "$cwd" ]; then cd "$cwd" || exit 0; fi
280
+ fi
281
+
282
+ out=$(build_context 2>/dev/null) || out=
283
+ if [ -n "$(printf '%s' "$out" | tr -d '[:space:]')" ]; then
284
+ if [ "$target" = copilot ]; then
285
+ # Copilot only reads {"additionalContext": "..."} from sessionStart hooks.
286
+ printf '{"additionalContext":"%s"}\n' "$(printf '%s\n' "$out" | json_escape)"
287
+ else
288
+ printf '%s\n' "$out"
289
+ fi
290
+ fi
291
+ # After the output: pick up what a killed session didn't log, credited to that
292
+ # session, and remember this one. In the background so startup stays instant.
293
+ if [ "$target" != text ]; then
294
+ startup_harvest "$(session_ref "$target" "$input")" </dev/null >/dev/null 2>&1 &
295
+ [ -n "${TRAILKEEP_SYNC:-}" ] && wait
296
+ fi
297
+ exit 0
298
+ }
299
+
300
+ startup_harvest() { # NEW_SESSION
301
+ in_repo || return 0
302
+ KEY=$(repo_key) || return 0
303
+ ld=$(log_dir "$KEY")
304
+ mkdir -p "$ld/.seen" 2>/dev/null || return 0
305
+ w=$ld/.seen/last-session-$(worktree_top | cksum | cut -d' ' -f1)
306
+ prev=$(cat "$w" 2>/dev/null) || prev=
307
+ harvest "${prev:-unknown}" ""
308
+ [ -n "$1" ] && printf '%s\n' "$1" > "$w"
309
+ return 0
310
+ }
311
+
312
+ # ------------------------------------------------------------------- log ----
313
+ # Append-only decision log, one file per repo per month:
314
+ # ~/.agents/repos/<repo>/log/YYYY-MM.tsv
315
+ # One event per line, tab-separated:
316
+ # id time type branch commit paths text session transcript
317
+ # Agents never write it (sandboxes would block that). They write Decisions and
318
+ # Progress in the effort file; the session hooks copy new lines in here.
319
+
320
+ TAB=$(printf '\t')
321
+
322
+ log_dir() { printf '%s/log\n' "$(repo_dir "$1")"; }
323
+
324
+ one_line() { tr '\t\r\n' ' ' | sed 's/ */ /g; s/^ //; s/ $//'; }
325
+
326
+ new_id() { od -An -N4 -tx1 /dev/urandom | tr -d ' \n' | cut -c1-7; }
327
+
328
+ log_append() { # KEY TYPE BRANCH COMMIT PATHS TEXT SESSION TRANSCRIPT
329
+ d=$(log_dir "$1")
330
+ mkdir -p "$d" 2>/dev/null || return 1
331
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$(new_id)" "$(date +%Y-%m-%dT%H:%M)" "$2" "$3" "${4:--}" "${5:--}" \
332
+ "$(printf '%s' "$6" | one_line)" "${7:--}" "${8:--}" >> "$d/$(date +%Y-%m).tsv"
333
+ }
334
+
335
+ # All events for a repo, newest first.
336
+ log_events() { # KEY
337
+ d=$(log_dir "$1")
338
+ for f in "$d"/*.tsv; do [ -f "$f" ] && cat "$f"; done | awk '{ a[NR] = $0 } END { for (i = NR; i > 0; i--) print a[i] }'
339
+ }
340
+
341
+ # Files touched since the last harvest on this branch (committed + uncommitted).
342
+ # Files a branch touched since LAST (or since it split from the default
343
+ # branch), excluding upstream changes merged in; plus uncommitted changes when
344
+ # it's the current branch. Up to 50 paths.
345
+ changed_paths() { # BRANCH LAST_SHA
346
+ tip=refs/heads/$1
347
+ git show-ref --verify --quiet "$tip" || tip=HEAD
348
+ set -- "$tip" "${2:-}"
349
+ excl=
350
+ if [ -n "$2" ] && git cat-file -e "$2^{commit}" 2>/dev/null; then excl=$2; fi
351
+ db=$(default_branch 2>/dev/null) || db=
352
+ { if [ -n "$excl" ] || { [ -n "$db" ] && [ "refs/heads/$db" != "$1" ]; }; then
353
+ # shellcheck disable=SC2086
354
+ git --no-optional-locks log --format= --name-only "$1" --not $excl ${db:+"refs/heads/$db"} 2>/dev/null
355
+ fi
356
+ if [ "$1" = HEAD ] || [ "$1" = "refs/heads/$(current_branch 2>/dev/null)" ]; then
357
+ git --no-optional-locks diff --name-only HEAD 2>/dev/null
358
+ fi; } | awk 'NF && !seen[$0]++' |
359
+ awk 'NR <= 50 { printf "%s%s", (NR > 1 ? "," : ""), $0 } END { if (NR > 50) printf ",+%d more", NR - 50 }'
360
+ }
361
+
362
+ # Copy new Decisions/Progress lines from the current branch's effort file into
363
+ # the log, and archive efforts whose branches were deleted. Silent, best effort.
364
+ # Serialise harvests per repo (session end and session start can overlap).
365
+ lock_log() { # DIR
366
+ l=$1/.lock; i=0
367
+ while ! mkdir "$l" 2>/dev/null; do
368
+ if [ -n "$(find "$l" -prune -mmin +1 2>/dev/null)" ]; then rmdir "$l" 2>/dev/null; continue; fi
369
+ i=$((i + 1)); [ "$i" -ge 30 ] && return 1
370
+ sleep 0.1 2>/dev/null || sleep 1
371
+ done
372
+ }
373
+ unlock_log() { rmdir "$1/.lock" 2>/dev/null; }
374
+
375
+ # A deleted branch that was really renamed: git's reflog says where it went.
376
+ renamed_to() { # OLD_BRANCH
377
+ gd=$(git rev-parse --git-common-dir 2>/dev/null) || return 1
378
+ [ -d "$gd/logs/refs/heads" ] || return 1
379
+ grep -rhF "renamed refs/heads/$1 to refs/heads/" "$gd/logs/refs/heads" 2>/dev/null | tail -n 1 |
380
+ awk -v m="renamed refs/heads/$1 to refs/heads/" '{ i = index($0, m); if (i) print substr($0, i + length(m)) }' | awk 'NF'
381
+ }
382
+
383
+ # Copy one effort file's new Decisions/Progress lines into the log (one awk pass).
384
+ harvest_file() { # FILE BRANCH SESSION TRANSCRIPT
385
+ seen=$ld/.seen/$(branch_file_id "$2")
386
+ touch "$seen" 2>/dev/null || return 0
387
+ new=$(awk -v tab="$TAB" -v seenf="$seen" '
388
+ BEGIN { while ((getline x < seenf) > 0) seen[x] = 1; close(seenf) }
389
+ /^## / { sec = ($0 == "## Decisions and dead ends") ? "decision" : ($0 == "## Progress") ? "progress" : ""; next }
390
+ sec != "" {
391
+ l = $0; sub(/^[[:space:]]*[-*][[:space:]]*/, "", l); gsub(/[\t\r]/, " ", l); sub(/[[:space:]]+$/, "", l)
392
+ if (l == "" || l ~ /^\[[ xX]\]/ || (l in seen)) next
393
+ seen[l] = 1; t = sec
394
+ if (l ~ /^([Dd]ead[ -]end|[Tt]ried |[Rr]ejected|[Rr]everted)/) t = "dead-end"
395
+ print t tab l
396
+ }' "$1")
397
+ [ -n "$new" ] || return 0
398
+ last=$(cat "$seen.sha" 2>/dev/null)
399
+ sha=$(git rev-parse --short "refs/heads/$2" 2>/dev/null || git rev-parse --short HEAD 2>/dev/null) || sha=-
400
+ paths=$(changed_paths "$2" "$last")
401
+ base=$(od -An -N3 -tx1 /dev/urandom | tr -d ' \n')
402
+ mkdir -p "$ld" 2>/dev/null || return 0
403
+ printf '%s\n' "$new" | awk -F "$TAB" -v OFS="$TAB" -v base="$base" -v ts="$(date +%Y-%m-%dT%H:%M)" \
404
+ -v br="$2" -v sha="${sha:--}" -v paths="${paths:--}" -v ses="${3:--}" -v tr="${4:--}" \
405
+ '{ printf "%s%x%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s\n", base, NR, OFS, ts, OFS, $1, OFS, br, OFS, sha, OFS, paths, OFS, $2, OFS, ses, OFS, tr }' \
406
+ >> "$ld/$(date +%Y-%m).tsv" || return 0
407
+ printf '%s\n' "$new" | cut -f2- >> "$seen"
408
+ printf '%s\n' "$sha" > "$seen.sha"
409
+ }
410
+
411
+ # Harvest every effort in this worktree, follow renamed branches, then
412
+ # archive efforts whose branches are gone. Silent, best effort, locked.
413
+ harvest() { # SESSION TRANSCRIPT
414
+ in_repo || return 0
415
+ KEY=$(repo_key) || return 0
416
+ ld=$(log_dir "$KEY")
417
+ mkdir -p "$ld/.seen" 2>/dev/null || return 0
418
+ lock_log "$ld" || return 0
419
+ ed=$(effort_dir)
420
+ gone_efforts | while IFS="$TAB" read -r br f; do
421
+ [ -n "$f" ] || continue
422
+ nb=$(renamed_to "$br") || nb=
423
+ if [ -z "$nb" ] || ! git show-ref --verify --quiet "refs/heads/$nb"; then continue; fi
424
+ nf=$ed/$(branch_file_id "$nb").md
425
+ [ -e "$nf" ] && continue
426
+ sed "s/^Branch: .* · Repo:/Branch: $(printf '%s' "$nb" | sed 's/[&/\]/\\&/g') · Repo:/" "$f" > "$nf" && rm -f "$f"
427
+ for x in "" .sha; do [ -f "$ld/.seen/$(branch_file_id "$br")$x" ] && mv "$ld/.seen/$(branch_file_id "$br")$x" "$ld/.seen/$(branch_file_id "$nb")$x"; done
428
+ log_append "$KEY" note "$nb" - - "effort followed branch rename from $br" "$1" "$2"
429
+ done
430
+ for f in "$ed"/*.md; do
431
+ [ -f "$f" ] || continue
432
+ br=$(effort_branch "$f"); [ -n "$br" ] || continue
433
+ harvest_file "$f" "$br" "$1" "$2"
434
+ done
435
+ gone_efforts | while IFS="$TAB" read -r br f; do
436
+ [ -n "$f" ] || continue
437
+ if dest=$(archive_effort "$f" "$KEY"); then
438
+ log_append "$KEY" closed "$br" - - "effort auto-archived: branch deleted ($(tilde "$dest"))" "$1" "$2"
439
+ fi
440
+ done
441
+ unlock_log "$ld"
442
+ return 0
443
+ }
444
+
445
+ session_ref() { # TOOL JSON -> tool:session-id
446
+ sid=$(printf '%s' "$2" | json_field session_id)
447
+ [ -n "$sid" ] || sid=$(printf '%s' "$2" | json_field sessionId)
448
+ [ -n "$sid" ] && printf '%s:%s\n' "$1" "$sid"
449
+ }
450
+
451
+ cmd_harvest() { # hook entry point (session end)
452
+ target=text
453
+ case ${1:-} in --for) target=${2:-text} ;; --for=*) target=${1#--for=} ;; esac
454
+ input=
455
+ if [ "$target" != text ] && [ ! -t 0 ]; then input=$(cat); fi
456
+ if [ -n "$input" ]; then
457
+ cwd=$(printf '%s' "$input" | json_field cwd)
458
+ if [ -n "$cwd" ] && [ -d "$cwd" ]; then cd "$cwd" || exit 0; fi
459
+ fi
460
+ harvest "$(session_ref "$target" "$input")" "$(printf '%s' "$input" | json_field transcript_path)" >/dev/null 2>&1
461
+ exit 0
462
+ }
463
+
464
+ log_usage() {
465
+ cat <<EOF
466
+ trailkeep log [--path P] [--grep TEXT] [--type T] [--branch B] [--since YYYY-MM-DD] [-n N]
467
+ trailkeep log show ID
468
+ trailkeep log add TYPE TEXT types: decision, dead-end, progress, note
469
+ EOF
470
+ }
471
+
472
+ cmd_log() {
473
+ need_repo
474
+ case ${1:-} in
475
+ show) shift; log_show "$@"; return ;;
476
+ add) shift; [ $# -ge 2 ] || die "usage: trailkeep log add TYPE TEXT"
477
+ typ=$1; shift
478
+ b=$(current_branch 2>/dev/null) || b=-
479
+ log_append "$KEY" "$typ" "${b:--}" "$(git rev-parse --short HEAD 2>/dev/null || echo -)" - "$*" "manual" - || die "could not write the log"
480
+ say "Logged."; return ;;
481
+ -h|--help|help) log_usage; return ;;
482
+ esac
483
+ fpath=; fgrep=; ftype=; fbranch=; fsince=; limit=20
484
+ while [ $# -gt 0 ]; do
485
+ case $1 in
486
+ --path|--grep|--type|--branch|--since|-n)
487
+ [ $# -ge 2 ] || die "$1 needs a value"
488
+ case $1 in
489
+ --path) fpath=$2 ;; --grep) fgrep=$2 ;; --type) ftype=$2 ;;
490
+ --branch) fbranch=$2 ;; --since) fsince=$2 ;; -n) limit=$2 ;;
491
+ esac
492
+ shift 2 ;;
493
+ *) log_usage >&2; exit 2 ;;
494
+ esac
495
+ done
496
+ if [ -n "$fpath" ]; then
497
+ # make the path repo-relative, like the logged ones
498
+ case $fpath in /*) top=$(worktree_top); fpath=${fpath#"$top"/} ;; *) fpath=$(git rev-parse --show-prefix 2>/dev/null)${fpath#./} ;; esac
499
+ fpath=${fpath%/}
500
+ fi
501
+ case $limit in ''|*[!0-9]*) die "-n needs a number" ;; esac
502
+ out=$(log_events "$KEY" | awk -F "$TAB" -v p="$fpath" -v g="$fgrep" -v t="$ftype" -v b="$fbranch" -v s="$fsince" -v n="$limit" '
503
+ function has_path(list, want, k, parts, i) {
504
+ k = split(list, parts, ",")
505
+ for (i = 1; i <= k; i++) if (parts[i] == want || index(parts[i], want "/") == 1) return 1
506
+ return 0
507
+ }
508
+ (p == "" || has_path($6, p)) && (g == "" || index(tolower($7), tolower(g)) > 0) &&
509
+ (t == "" || $3 == t) && (b == "" || $4 == b) && (s == "" || substr($2, 1, 10) >= s) {
510
+ txt = $7; if (length(txt) > 140) txt = substr(txt, 1, 137) "..."
511
+ printf "%s %s %-8s %s %s %s\n", $1, substr($2, 1, 10), $3, $4, $5, txt
512
+ if (++c >= n) exit
513
+ }')
514
+ if [ -n "$out" ]; then printf '%s\n' "$out"; else say "No matching log entries."; fi
515
+ }
516
+
517
+ log_show() { # ID (prefix ok)
518
+ [ -n "${1:-}" ] || die "usage: trailkeep log show ID"
519
+ line=$(log_events "$KEY" | awk -F "$TAB" -v id="$1" 'index($1, id) == 1 { print; exit }')
520
+ [ -n "$line" ] || die "no log entry $1"
521
+ printf '%s\n' "$line" | awk -F "$TAB" '{
522
+ print "id: " $1; print "time: " $2; print "type: " $3; print "branch: " $4
523
+ print "commit: " $5 (($5 != "-") ? " (git show " $5 ")" : "")
524
+ print "paths: " $6; print "session: " $8
525
+ if ($9 != "-") print "transcript: " $9
526
+ print ""; print $7 }'
527
+ }
528
+
529
+ # ----------------------------------------------------------- effort cmds ----
530
+
531
+ effort_template() { # NAME KEY KIND
532
+ cat <<EOF
533
+ # $1
534
+
535
+ Branch: $1 · Repo: $2 · Kind: $3 · Created: $(today)
536
+
537
+ ## Goal
538
+
539
+
540
+ ## Done when
541
+
542
+
543
+ ## Plan
544
+
545
+ - [ ]
546
+
547
+ ## Progress
548
+
549
+
550
+ ## Decisions and dead ends
551
+
552
+
553
+ ## Open questions
554
+
555
+ EOF
556
+ }
557
+
558
+ cmd_new() {
559
+ need_repo; need_branch
560
+ f=$(effort_path "$KEY" "$BRANCH")
561
+ if [ -e "$f" ]; then
562
+ say "Effort already exists: $f"
563
+ return 0
564
+ fi
565
+ kind=branch; [ -n "${TRAILKEEP_EFFORT:-}" ] && kind=named
566
+ ensure_effort_dir
567
+ effort_template "$BRANCH" "$KEY" "$kind" > "$f" || die "could not write $f"
568
+ remember_repo
569
+ say "Created $f"
570
+ say "Fill in Goal, Done when and Plan (or ask your agent to, with the effort skill)."
571
+ }
572
+
573
+ cmd_path() {
574
+ need_repo
575
+ case ${1:-} in
576
+ --notes) notes_path "$KEY" ;;
577
+ --repo) repo_dir "$KEY" ;;
578
+ --dir) effort_dir ;;
579
+ "") need_branch; effort_path "$KEY" "$BRANCH" ;;
580
+ *) die "path: unknown option $1" ;;
581
+ esac
582
+ }
583
+
584
+ cmd_show() {
585
+ need_repo
586
+ out=$(build_context)
587
+ if [ -n "$out" ]; then printf '%s\n' "$out"; else say "Nothing to inject for this repo/branch (no notes, no effort)."; fi
588
+ }
589
+
590
+ open_editor() {
591
+ ed=${VISUAL:-${EDITOR:-vi}}
592
+ # shellcheck disable=SC2086
593
+ $ed "$1"
594
+ }
595
+
596
+ cmd_edit() {
597
+ need_repo; need_branch
598
+ f=$(effort_path "$KEY" "$BRANCH")
599
+ [ -e "$f" ] || die "no effort for branch $BRANCH (run: trailkeep new)"
600
+ open_editor "$f"
601
+ }
602
+
603
+ cmd_notes() {
604
+ need_repo
605
+ f=$(notes_path "$KEY")
606
+ if [ "${1:-}" = --path ]; then printf '%s\n' "$f"; return 0; fi
607
+ if [ ! -e "$f" ]; then
608
+ mkdir -p "$(dirname "$f")"
609
+ printf '# Private notes: %s\n\n' "$KEY" > "$f"
610
+ remember_repo
611
+ fi
612
+ if [ -t 0 ] && [ -t 1 ]; then open_editor "$f"; else printf '%s\n' "$f"; fi
613
+ }
614
+
615
+ last_progress() {
616
+ section "$1" Progress | awk 'NF { l = $0 } END { print l }'
617
+ }
618
+
619
+ list_dir() { # print efforts in one .trailkeep dir
620
+ for f in "$1"/*.md; do
621
+ [ -e "$f" ] || continue
622
+ br=$(effort_branch "$f"); [ -n "$br" ] || br=$(basename "$f" .md)
623
+ lp=$(last_progress "$f")
624
+ printf '%s %s %s\n' "$2" "$br" "$(tilde "$(dirname "$1")")"
625
+ printf ' %s\n' "${lp:-(no progress yet)}"
626
+ LISTED=1
627
+ done
628
+ }
629
+
630
+ cmd_list() {
631
+ LISTED=0; seen=
632
+ for reg in "$REPOS"/*/worktrees; do
633
+ [ -f "$reg" ] || continue
634
+ key=$(basename "$(dirname "$reg")")
635
+ while IFS= read -r w; do
636
+ [ -d "$w/.trailkeep" ] || continue
637
+ case "
638
+ $seen
639
+ " in *"
640
+ $w
641
+ "*) continue ;; esac
642
+ seen="$seen
643
+ $w"
644
+ list_dir "$w/.trailkeep" "$key"
645
+ done < "$reg"
646
+ done
647
+ if in_repo && KEY=$(repo_key); then
648
+ w=$(worktree_top)
649
+ case "
650
+ $seen
651
+ " in *"
652
+ $w
653
+ "*) ;; *) [ -d "$w/.trailkeep" ] && list_dir "$w/.trailkeep" "$KEY" ;; esac
654
+ fi
655
+ [ "$LISTED" -eq 1 ] || say "No active efforts."
656
+ }
657
+
658
+ archive_effort() { # FILE KEY -> prints destination; archive lives in ~/.agents, kept after the worktree is gone
659
+ a=$(repo_dir "$2")/archive
660
+ mkdir -p "$a" 2>/dev/null || return 1
661
+ dest=$a/$(today)-$(basename "$1")
662
+ n=1
663
+ while [ -e "$dest" ]; do dest=$a/$(today)-$n-$(basename "$1"); n=$((n + 1)); done
664
+ cp "$1" "$dest" 2>/dev/null || return 1
665
+ rm -f "$1"
666
+ printf '%s\n' "$dest"
667
+ }
668
+
669
+ cmd_close() {
670
+ need_repo; need_branch
671
+ f=$(effort_path "$KEY" "$BRANCH")
672
+ [ -e "$f" ] || die "no effort for branch $BRANCH"
673
+ dec=$(section "$f" "Decisions and dead ends" | awk 'NF')
674
+ harvest manual - >/dev/null 2>&1
675
+ dest=$(archive_effort "$f" "$KEY") || die "could not write to $(tilde "$(repo_dir "$KEY")") (sandboxed agent?). Run 'trailkeep close' yourself in a terminal."
676
+ log_append "$KEY" closed "$BRANCH" "$(git rev-parse --short HEAD 2>/dev/null || echo -)" - "effort closed ($(tilde "$dest"))" manual -
677
+ say "Archived to $dest"
678
+ if [ -n "$dec" ]; then
679
+ say ""
680
+ say "Decisions recorded for this effort. Promote any that every future task needs:"
681
+ printf '%s\n' "$dec"
682
+ say ""
683
+ say " team-wide, not derivable from code -> repo AGENTS.md"
684
+ say " private to you, this repo -> trailkeep notes"
685
+ say " you, every repo -> $(tilde "$PERSONAL")"
686
+ fi
687
+ }
688
+
689
+ effort_branch() { # original branch/effort name from the header line
690
+ sed -n 's/^Branch: \([^·]*\) ·.*/\1/p' "$1" | head -n 1 | sed 's/ *$//'
691
+ }
692
+
693
+ effort_kind() { if grep -q '^Branch: .* · Kind: named ·' "$1"; then echo named; else echo branch; fi; }
694
+
695
+ # Efforts in this worktree whose branch no longer exists (named efforts never count).
696
+ gone_efforts() {
697
+ cur=$(current_branch 2>/dev/null) || cur=
698
+ for f in "$(effort_dir)"/*.md; do
699
+ [ -e "$f" ] || continue
700
+ [ "$(effort_kind "$f")" = named ] && continue
701
+ br=$(effort_branch "$f")
702
+ [ -n "$br" ] || continue
703
+ [ "$br" = "$cur" ] && continue # the branch you're on (maybe with no commits yet)
704
+ git show-ref --verify --quiet "refs/heads/$br" && continue
705
+ # still on a remote: not gone yet (it disappears after fetch --prune)
706
+ [ -n "$(git for-each-ref --format='%(refname)' "refs/remotes/*/$br" 2>/dev/null)" ] && continue
707
+ printf '%s\t%s\n' "$br" "$f"
708
+ done
709
+ }
710
+
711
+ cmd_prune() {
712
+ need_repo
713
+ apply=0; [ "${1:-}" = --apply ] && apply=1
714
+ list=$(gone_efforts)
715
+ if [ -z "$list" ]; then say "No efforts for deleted branches."; return 0; fi
716
+ printf '%s\n' "$list" | while IFS="$(printf '\t')" read -r br f; do
717
+ if [ "$apply" -eq 1 ]; then
718
+ d=$(archive_effort "$f" "$KEY") || die "could not write to $(tilde "$(repo_dir "$KEY")")"
719
+ say "archived $d (branch $br is gone)"
720
+ else
721
+ say "$br (branch gone) $f"
722
+ fi
723
+ done
724
+ [ "$apply" -eq 1 ] || say "Run 'trailkeep prune --apply' to archive them."
725
+ }
726
+
727
+ # --------------------------------------------------------------- install ----
728
+
729
+ claude_import_line() {
730
+ case $PERSONAL in
731
+ "$HOME"/*) printf '@~/%s\n' "${PERSONAL#"$HOME"/}" ;;
732
+ *) printf '@%s\n' "$PERSONAL" ;;
733
+ esac
734
+ }
735
+
736
+ claude_block() {
737
+ printf '<!-- trailkeep:begin (%s; edit %s instead) -->\n%s\n<!-- trailkeep:end -->\n' "$MARKER" "$(tilde "$PERSONAL")" "$(claude_import_line)"
738
+ }
739
+
740
+ strip_block() { # print FILE without the trailkeep block
741
+ awk '/^<!-- trailkeep:begin/ { skip = 1; next } skip && /^<!-- trailkeep:end -->[[:space:]]*$/ { skip = 0; next } !skip' "$1"
742
+ }
743
+
744
+ block_ok() { # FILE has no block, or exactly one well-formed block
745
+ b=$(grep -c '^<!-- trailkeep:begin' "$1" 2>/dev/null); e=$(grep -c '^<!-- trailkeep:end -->[[:space:]]*$' "$1" 2>/dev/null)
746
+ [ "${b:-0}" -eq "${e:-0}" ] && [ "${b:-0}" -le 1 ]
747
+ }
748
+
749
+ block_body() { awk '/^<!-- trailkeep:begin/ { s = 1; next } /^<!-- trailkeep:end -->[[:space:]]*$/ { s = 0; next } s' "$1"; }
750
+
751
+ has_content() { # non-blank, non-comment content
752
+ [ -f "$1" ] && grep -v '^[[:space:]]*$' "$1" | grep -qv '^[[:space:]]*<!--.*-->[[:space:]]*$'
753
+ }
754
+
755
+ is_link_to() { [ -L "$1" ] && [ "$(readlink "$1")" = "$2" ]; }
756
+
757
+ codex_hook_group() {
758
+ printf ' {\n "matcher": "startup|clear|compact",\n "hooks": [\n { "type": "command", "command": "%s context --for codex", "timeout": 10 }\n ]\n }\n' "'$RT_BIN'"
759
+ }
760
+
761
+ codex_end_group() {
762
+ printf ' {\n "hooks": [\n { "type": "command", "command": "%s harvest --for codex", "timeout": 3 }\n ]\n }\n' "'$RT_BIN'"
763
+ }
764
+
765
+ codex_hooks_json() {
766
+ printf '{\n "description": "%s: injects private repo notes and the active effort file, logs decisions at session end",\n "hooks": {\n "SessionStart": [\n%s\n ],\n "SessionEnd": [\n%s\n ]\n }\n}\n' "$MARKER" "$(codex_hook_group)" "$(codex_end_group)"
767
+ }
768
+
769
+ copilot_hook_entry() {
770
+ printf ' { "type": "command", "bash": "%s context --for copilot", "timeoutSec": 10 }\n' "'$RT_BIN'"
771
+ }
772
+
773
+ copilot_end_entry() {
774
+ printf ' { "type": "command", "bash": "%s harvest --for copilot", "timeoutSec": 5 }\n' "'$RT_BIN'"
775
+ }
776
+
777
+ copilot_hooks_json() {
778
+ printf '{\n "version": 1,\n "description": "%s",\n "hooks": {\n "sessionStart": [\n%s\n ],\n "sessionEnd": [\n%s\n ]\n }\n}\n' "$MARKER" "$(copilot_hook_entry)" "$(copilot_end_entry)"
779
+ }
780
+
781
+ # We own a hook file only if its description carries our marker.
782
+ owned() { [ -f "$1" ] && grep -q "\"description\": \"$MARKER" "$1"; }
783
+
784
+ personal_template() {
785
+ cat <<'EOF'
786
+ # Personal instructions
787
+ EOF
788
+ }
789
+
790
+ # Link a tool's global instruction file to the personal file.
791
+ link_global() { # TOOL PATH
792
+ t=$2
793
+ if is_link_to "$t" "$PERSONAL"; then
794
+ ok "$1 global instructions -> $(tilde "$PERSONAL")"
795
+ return 0
796
+ fi
797
+ if [ -L "$t" ]; then
798
+ warn "$1: $(tilde "$t") is a link to $(readlink "$t"), left untouched"
799
+ hint "point it at $(tilde "$PERSONAL") yourself if you want trailkeep to manage it"
800
+ return 0
801
+ fi
802
+ if [ -e "$t" ]; then
803
+ if has_content "$t"; then
804
+ warn "$1: $(tilde "$t") has its own content, left untouched"
805
+ hint "merge it into $(tilde "$PERSONAL"), then run: trailkeep install (or use --adopt)"
806
+ return 0
807
+ fi
808
+ backup "$t"
809
+ act "replace $(tilde "$t") with a link to $(tilde "$PERSONAL")" rm -f "$t"
810
+ fi
811
+ act "link $(tilde "$t") -> $(tilde "$PERSONAL")" make_link "$t" "$PERSONAL"
812
+ }
813
+
814
+ # Write a hook file we own; never overwrite one we don't.
815
+ own_file() { # LABEL PATH CONTENT SNIPPET
816
+ if [ -e "$2" ] && ! owned "$2"; then
817
+ warn "$1: $(tilde "$2") exists and is not managed by trailkeep, left untouched"
818
+ hint "add these entries to it by hand:"
819
+ printf '%s\n' "$4" | sed 's/^/ /'
820
+ return 0
821
+ fi
822
+ if [ -e "$2" ] && [ "$(cat "$2")" = "$3" ]; then ok "$1: $(tilde "$2")"; return 0; fi
823
+ act "write $(tilde "$2")" write_file "$2" "$3"
824
+ }
825
+
826
+ adopt_sources() { # list global files that hold instructions we could adopt
827
+ if [ -f "$CLAUDE_DIR/CLAUDE.md" ]; then
828
+ tmp=$(mktemp); strip_block "$CLAUDE_DIR/CLAUDE.md" > "$tmp"
829
+ has_content "$tmp" && printf '%s\n' "$CLAUDE_DIR/CLAUDE.md"
830
+ rm -f "$tmp"
831
+ fi
832
+ for f in "$CODEX_DIR/AGENTS.md" "$COPILOT_DIR/copilot-instructions.md"; do
833
+ [ -L "$f" ] && continue
834
+ has_content "$f" && printf '%s\n' "$f"
835
+ done
836
+ }
837
+
838
+ ensure_personal() {
839
+ if [ -e "$PERSONAL" ]; then ok "personal instructions: $(tilde "$PERSONAL")"; return 0; fi
840
+ srcs=$(adopt_sources)
841
+ count=$(printf '%s' "$srcs" | awk 'NF' | wc -l | tr -d ' ')
842
+ if [ "$ADOPT" -eq 1 ]; then
843
+ if [ "$count" -eq 1 ]; then
844
+ backup "$srcs"
845
+ if [ "$srcs" = "$CLAUDE_DIR/CLAUDE.md" ]; then
846
+ act "move your instructions from $(tilde "$srcs") into $(tilde "$PERSONAL")" adopt_claude_md "$srcs" "$PERSONAL"
847
+ if [ -f "$PERSONAL" ] && prose_only "$PERSONAL" | grep -qE '(^|[[:space:]])@[^[:space:]]'; then
848
+ warn "the adopted text has @imports: they now resolve from $(tilde "$TRAILKEEP_HOME"), and Codex/Copilot don't expand them"
849
+ hint "inline what they point to, or make the paths absolute for Claude"
850
+ fi
851
+ else
852
+ act "move $(tilde "$srcs") to $(tilde "$PERSONAL")" move_file "$srcs" "$PERSONAL"
853
+ fi
854
+ return 0
855
+ elif [ "$count" -gt 1 ]; then
856
+ fail "--adopt: more than one global file has content; merge them by hand into $(tilde "$PERSONAL"):"
857
+ printf '%s\n' "$srcs" | sed 's/^/ /'
858
+ return 1
859
+ fi
860
+ elif [ "$count" -eq 1 ] && [ "$DRY" -eq 0 ] && [ -t 0 ] && [ -t 1 ] && [ -z "${CLAUDECODE:-}${CODEX_SANDBOX:-}${COPILOT_AGENT:-}" ]; then
861
+ printf ' Found your instructions in %s.\n Move them to %s so Claude Code, Codex and Copilot all use them? [y/N] ' "$(tilde "$srcs")" "$(tilde "$PERSONAL")"
862
+ ans=; read -r ans || ans=
863
+ case $ans in [Yy]*) ADOPT=1; ensure_personal; return $? ;; esac
864
+ fi
865
+ if [ "$ADOPT" -eq 0 ] && [ "$count" -gt 0 ]; then
866
+ info "existing global instructions found (Codex/Copilot/Claude don't share them yet):"
867
+ printf '%s\n' "$srcs" | sed 's/^/ /'
868
+ hint "rerun with --adopt to move a single one into $(tilde "$PERSONAL")"
869
+ fi
870
+ act "create $(tilde "$PERSONAL")" write_file "$PERSONAL" "$(personal_template)"
871
+ }
872
+
873
+ install_claude() {
874
+ say ""; say "Claude Code"
875
+ f=$CLAUDE_DIR/CLAUDE.md
876
+ if [ -f "$f" ] && ! block_ok "$f"; then
877
+ fail "$(tilde "$f") has a broken or duplicated trailkeep block; fix it by hand (keep one begin/end pair)"
878
+ elif [ -f "$f" ] && grep -q '^<!-- trailkeep:begin' "$f"; then
879
+ if [ "$(block_body "$f")" = "$(claude_import_line)" ]; then
880
+ ok "$(tilde "$f") imports $(tilde "$PERSONAL")"
881
+ else
882
+ backup "$f"
883
+ act "refresh the trailkeep block in $(tilde "$f")" put_block "$f" "$(claude_block)"
884
+ fi
885
+ else
886
+ backup "$f"
887
+ act "add an import of $(tilde "$PERSONAL") to $(tilde "$f")" put_block "$f" "$(claude_block)"
888
+ fi
889
+
890
+ if ! has claude; then
891
+ warn "claude not on PATH: the hook plugin was not installed"
892
+ hint "install Claude Code, then rerun: trailkeep install"
893
+ return 0
894
+ fi
895
+ if claude plugin marketplace list 2>/dev/null | grep -q 'trailkeep'; then
896
+ act "refresh the trailkeep plugin marketplace" quiet claude plugin marketplace update trailkeep
897
+ else
898
+ act "add the trailkeep plugin marketplace ($(tilde "$RUNTIME"))" quiet claude plugin marketplace add "$RUNTIME" --scope user
899
+ fi
900
+ if claude plugin list 2>/dev/null | grep -q 'trailkeep@trailkeep'; then
901
+ act "update the trailkeep plugin (SessionStart hook + effort skill)" quiet claude plugin update trailkeep@trailkeep --scope user
902
+ else
903
+ act "install the trailkeep plugin (SessionStart hook + effort skill)" quiet claude plugin install trailkeep@trailkeep --scope user
904
+ fi
905
+ }
906
+
907
+ install_codex() {
908
+ say ""; say "Codex CLI"
909
+ link_global "Codex" "$CODEX_DIR/AGENTS.md"
910
+ if has_content "$CODEX_DIR/AGENTS.override.md"; then
911
+ warn "Codex: $(tilde "$CODEX_DIR/AGENTS.override.md") exists and wins over AGENTS.md"
912
+ hint "move its content into $(tilde "$PERSONAL") and delete it"
913
+ fi
914
+ own_file "Codex hooks" "$CODEX_DIR/hooks.json" "$(codex_hooks_json)" "SessionStart:
915
+ $(codex_hook_group)
916
+ SessionEnd:
917
+ $(codex_end_group)"
918
+ info "Codex runs new hooks only after you trust them: open codex, run /hooks, and trust both trailkeep hooks (once per machine)"
919
+ }
920
+
921
+ install_copilot() {
922
+ say ""; say "Copilot CLI"
923
+ link_global "Copilot" "$COPILOT_DIR/copilot-instructions.md"
924
+ own_file "Copilot hooks" "$COPILOT_DIR/hooks/trailkeep.json" "$(copilot_hooks_json)" "sessionStart:
925
+ $(copilot_hook_entry)
926
+ sessionEnd:
927
+ $(copilot_end_entry)"
928
+ }
929
+
930
+ install_skill() {
931
+ say ""; say "Effort skill"
932
+ link_skill "$SKILLS_DIR/effort" "Codex and Copilot"
933
+ # Copilot stops reading ~/.agents/skills when COPILOT_HOME is set.
934
+ [ -n "${COPILOT_HOME:-}" ] && link_skill "$COPILOT_DIR/skills/effort" "Copilot (COPILOT_HOME)"
935
+ ok "Claude Code gets the skill from the plugin (as trailkeep:effort)"
936
+ }
937
+
938
+ link_skill() { # PATH LABEL
939
+ if is_link_to "$1" "$RUNTIME/skills/effort"; then ok "$2: $(tilde "$1")"; return 0; fi
940
+ if [ -e "$1" ] || [ -L "$1" ]; then
941
+ warn "$(tilde "$1") already exists, left untouched"
942
+ return 0
943
+ fi
944
+ act "link $(tilde "$1") -> $(tilde "$RUNTIME/skills/effort")" make_link "$1" "$RUNTIME/skills/effort"
945
+ }
946
+
947
+ # Copy the tool into $RUNTIME (atomically swapped), remembering where it came from.
948
+ install_runtime() {
949
+ if [ -d "$RUNTIME" ] && [ "$ROOT" = "$(cd "$RUNTIME" && pwd -P)" ]; then ok "runtime: $(tilde "$RUNTIME")"; return 0; fi
950
+ act "copy trailkeep $VERSION to $(tilde "$RUNTIME")" copy_runtime
951
+ }
952
+
953
+ copy_runtime() {
954
+ new=$RUNTIME.new.$$
955
+ rm -rf "$new" && mkdir -p "$new" || return 1
956
+ for x in bin hooks skills .claude-plugin README.md LICENSE; do
957
+ [ -e "$ROOT/$x" ] && { cp -R "$ROOT/$x" "$new/" || return 1; }
958
+ done
959
+ chmod +x "$new/bin/trailkeep"
960
+ src=git; [ -d "$ROOT/.git" ] || src=npm
961
+ case $ROOT in */node_modules/*) src=npm ;; esac
962
+ printf 'version=%s\nsource=%s\npath=%s\n' "$VERSION" "$src" "$ROOT" > "$new/.trailkeep-runtime"
963
+ if [ -e "$RUNTIME" ]; then
964
+ [ -f "$RUNTIME/.trailkeep-runtime" ] || { rm -rf "$new"; printf '%s exists and is not an trailkeep runtime\n' "$RUNTIME" >&2; return 1; }
965
+ rm -rf "$RUNTIME.old.$$" && mv "$RUNTIME" "$RUNTIME.old.$$" && mv "$new" "$RUNTIME" && rm -rf "$RUNTIME.old.$$"
966
+ else
967
+ mkdir -p "$(dirname "$RUNTIME")" && mv "$new" "$RUNTIME"
968
+ fi
969
+ }
970
+
971
+ install_bin() {
972
+ t=$BIN_DIR/trailkeep
973
+ if is_link_to "$t" "$RT_BIN"; then ok "command: $(tilde "$t")"; else
974
+ if [ -e "$t" ] && ! [ -L "$t" ]; then warn "$(tilde "$t") exists and is not a link, left untouched"; else
975
+ [ -L "$t" ] && act "remove old link $(tilde "$t")" rm -f "$t"
976
+ act "link $(tilde "$t") -> $(tilde "$RT_BIN")" make_link "$t" "$RT_BIN"
977
+ fi
978
+ fi
979
+ case ":$PATH:" in *":$BIN_DIR:"*) ;; *) warn "$(tilde "$BIN_DIR") is not on your PATH"; hint "add it in your shell rc so agents can run trailkeep" ;; esac
980
+ }
981
+
982
+ want() { # TOOL DIR -> should we configure this tool?
983
+ case " $SKIP " in *" $1 "*) return 1 ;; esac
984
+ has "$1" || [ -d "$2" ]
985
+ }
986
+
987
+ cmd_install() {
988
+ SKIP=; ADOPT=0; NO_SKILL=0
989
+ while [ $# -gt 0 ]; do
990
+ case $1 in
991
+ --dry-run) DRY=1 ;;
992
+ --adopt) ADOPT=1 ;;
993
+ --no-claude) SKIP="$SKIP claude" ;;
994
+ --no-codex) SKIP="$SKIP codex" ;;
995
+ --no-copilot) SKIP="$SKIP copilot" ;;
996
+ --no-skill) NO_SKILL=1 ;;
997
+ *) die "install: unknown option $1" ;;
998
+ esac
999
+ shift
1000
+ done
1001
+ [ "$DRY" -eq 1 ] && say "Dry run: nothing will change."
1002
+ say "trailkeep $VERSION install"
1003
+ say ""; say "Shared"
1004
+ ensure_personal || exit 1
1005
+ [ "$DRY" -eq 1 ] || mkdir -p "$REPOS"
1006
+ install_runtime || { say "Could not set up $(tilde "$RUNTIME")."; exit 1; }
1007
+ install_bin
1008
+ any=0
1009
+ if want claude "$CLAUDE_DIR"; then install_claude; any=1; fi
1010
+ if want codex "$CODEX_DIR"; then install_codex; any=1; fi
1011
+ if want copilot "$COPILOT_DIR"; then install_copilot; any=1; fi
1012
+ [ "$any" -eq 1 ] || warn "no supported agent found (claude, codex, copilot)"
1013
+ [ "$NO_SKILL" -eq 1 ] || install_skill
1014
+ [ -d "$BACKUP_DIR" ] && { say ""; say "Backups of changed files: $(tilde "$BACKUP_DIR")"; }
1015
+ say ""
1016
+ if [ "$FAILS" -gt 0 ]; then say "$FAILS step(s) failed; see above."; exit 1; fi
1017
+ say "Next: trailkeep doctor (checks the setup) then trailkeep verify (live test with each agent)"
1018
+ }
1019
+
1020
+ # Replace the CLAUDE.md block with a plain copy of the personal file.
1021
+ inline_block() { # FILE
1022
+ tmp=$(mktemp) || return 1
1023
+ strip_block "$1" | awk 'NF { p = 1 } p' > "$tmp"
1024
+ { cat "$PERSONAL"; if [ -s "$tmp" ]; then printf '\n'; cat "$tmp"; fi; } > "$1"
1025
+ st=$?; rm -f "$tmp"; return $st
1026
+ }
1027
+
1028
+ copy_over_link() { rm -f "$1" && cp "$PERSONAL" "$1"; }
1029
+
1030
+ cmd_uninstall() {
1031
+ say "trailkeep uninstall"
1032
+ [ "${1:-}" = --dry-run ] && { DRY=1; say "Dry run: nothing will change."; }
1033
+ f=$CLAUDE_DIR/CLAUDE.md
1034
+ if [ -f "$f" ] && grep -q '^<!-- trailkeep:begin' "$f"; then
1035
+ if block_ok "$f"; then
1036
+ backup "$f"
1037
+ act "replace the import in $(tilde "$f") with a copy of your personal instructions" inline_block "$f"
1038
+ else
1039
+ fail "$(tilde "$f") has a broken trailkeep block; remove it by hand"
1040
+ fi
1041
+ fi
1042
+ if has claude; then
1043
+ if claude plugin list 2>/dev/null | grep -q 'trailkeep@trailkeep'; then act "uninstall the Claude Code plugin" quiet claude plugin uninstall trailkeep@trailkeep --scope user; fi
1044
+ if claude plugin marketplace list 2>/dev/null | grep -q 'trailkeep'; then act "remove the plugin marketplace" quiet claude plugin marketplace remove trailkeep; fi
1045
+ fi
1046
+ for l in "$CODEX_DIR/AGENTS.md" "$COPILOT_DIR/copilot-instructions.md"; do
1047
+ if is_link_to "$l" "$PERSONAL"; then act "replace link $(tilde "$l") with a copy of your personal instructions" copy_over_link "$l"; fi
1048
+ done
1049
+ for h in "$CODEX_DIR/hooks.json" "$COPILOT_DIR/hooks/trailkeep.json"; do
1050
+ if owned "$h"; then act "remove $(tilde "$h")" rm -f "$h"; fi
1051
+ done
1052
+ for sk in "$SKILLS_DIR/effort" "$COPILOT_DIR/skills/effort"; do
1053
+ if is_link_to "$sk" "$RUNTIME/skills/effort"; then act "remove link $(tilde "$sk")" rm -f "$sk"; fi
1054
+ done
1055
+ if is_link_to "$BIN_DIR/trailkeep" "$RT_BIN"; then act "remove $(tilde "$BIN_DIR/trailkeep")" rm -f "$BIN_DIR/trailkeep"; fi
1056
+ if [ -f "$RUNTIME/.trailkeep-runtime" ]; then act "remove $(tilde "$RUNTIME")" rm -rf "$RUNTIME"; fi
1057
+ say ""
1058
+ say "Each agent keeps a plain copy of your personal instructions."
1059
+ say "Kept: $(tilde "$TRAILKEEP_HOME") (personal file, notes, archive) and any .trailkeep/ folders in your worktrees."
1060
+ [ "$FAILS" -eq 0 ] || exit 1
1061
+ }
1062
+
1063
+ runtime_field() { sed -n "s/^$1=//p" "$RUNTIME/.trailkeep-runtime" 2>/dev/null; }
1064
+
1065
+ cmd_update() {
1066
+ src=$(runtime_field source); path=$(runtime_field path)
1067
+ [ -n "$src" ] || { src=git; [ -d "$ROOT/.git" ] || src=npm; path=$ROOT; }
1068
+ dry=0; case " $* " in *" --dry-run "*) dry=1 ;; esac
1069
+ case $src in
1070
+ git)
1071
+ [ -d "$path/.git" ] || die "the clone at $path is gone; clone trailkeep again and run install"
1072
+ if [ "$dry" -eq 1 ]; then say "Dry run: would git pull $(tilde "$path"), then:"
1073
+ else say "Updating $(tilde "$path")"; git -C "$path" pull --ff-only || die "git pull failed (local changes?)"; fi
1074
+ exec "$path/bin/trailkeep" install "$@" ;;
1075
+ npm)
1076
+ has npm || die "npm not found"
1077
+ if [ "$dry" -eq 1 ]; then say "Dry run: would run npm install -g $PKG@latest, then install"; exec "$RT_BIN" install "$@"; fi
1078
+ say "Updating with npm"
1079
+ npm install -g "$PKG@latest" || die "npm install failed"
1080
+ np=$(npm root -g)/$PKG/bin/trailkeep
1081
+ [ -x "$np" ] || die "trailkeep not found in $(npm root -g)"
1082
+ exec "$np" install "$@" ;;
1083
+ esac
1084
+ }
1085
+
1086
+ # ---------------------------------------------------------------- doctor ----
1087
+
1088
+ # Strip fenced code blocks and inline code spans (Claude ignores imports there).
1089
+ prose_only() {
1090
+ awk '/^[[:space:]]*(```|~~~)/ { f = !f; next } !f' "$1" | sed 's/`[^`]*`//g'
1091
+ }
1092
+
1093
+ check_imports() { # FILE
1094
+ dir=$(dirname "$1")
1095
+ prose_only "$1" | grep -oE '(^|[[:space:]])@[^[:space:]]+' | sed 's/^[[:space:]]*@//' | while IFS= read -r p; do
1096
+ case $p in */*|*.*) ;; *) continue ;; esac # @mentions are not imports
1097
+ p=${p%[),.;:]}
1098
+ case $p in
1099
+ "~/"*) full=$HOME/${p#"~/"} ;;
1100
+ /*) full=$p ;;
1101
+ *) full=$dir/$p ;;
1102
+ esac
1103
+ if [ -e "$full" ]; then :; else
1104
+ printf '%s\n' "$p"
1105
+ fi
1106
+ done
1107
+ }
1108
+
1109
+ check_size() { # LABEL FILE
1110
+ n=$(lines "$2")
1111
+ if [ "$n" -gt 200 ]; then
1112
+ warn "$1 is $n lines; Anthropic's guidance is under 200 (longer files reduce adherence)"
1113
+ hint "move area-specific parts to path-scoped rules / nested AGENTS.md, procedures to skills"
1114
+ else
1115
+ ok "$1: $n lines"
1116
+ fi
1117
+ }
1118
+
1119
+ doctor_personal() {
1120
+ say "Personal layer"
1121
+ if [ -f "$PERSONAL" ]; then
1122
+ check_size "$(tilde "$PERSONAL")" "$PERSONAL"
1123
+ check_imports "$PERSONAL" | while IFS= read -r p; do warn "$(tilde "$PERSONAL"): '@$p' looks like an import but the file doesn't exist"; done
1124
+ if prose_only "$PERSONAL" | grep -qE '(^|[[:space:]])@[^[:space:]]*[/.]'; then
1125
+ info "imports in $(tilde "$PERSONAL") work in Claude Code only; Codex and Copilot see them as plain text"
1126
+ fi
1127
+ else fail "missing $(tilde "$PERSONAL")"; hint "run: trailkeep install"; fi
1128
+
1129
+ f=$CLAUDE_DIR/CLAUDE.md
1130
+ if [ -f "$f" ] && grep -qF "$(claude_import_line)" "$f"; then
1131
+ ok "Claude Code imports it ($(tilde "$f"))"
1132
+ rest=$(mktemp); strip_block "$f" > "$rest"
1133
+ has_content "$rest" && info "$(tilde "$f") also has its own lines; Codex and Copilot don't see those"
1134
+ rm -f "$rest"
1135
+ elif has claude || [ -d "$CLAUDE_DIR" ]; then fail "Claude Code does not import it"; hint "run: trailkeep install"; fi
1136
+
1137
+ for pair in "Codex:$CODEX_DIR/AGENTS.md:codex:$CODEX_DIR" "Copilot:$COPILOT_DIR/copilot-instructions.md:copilot:$COPILOT_DIR"; do
1138
+ label=${pair%%:*}; rest=${pair#*:}; path=${rest%%:*}; rest=${rest#*:}; bin=${rest%%:*}; dir=${rest#*:}
1139
+ has "$bin" || [ -d "$dir" ] || continue
1140
+ if is_link_to "$path" "$PERSONAL"; then ok "$label reads it ($(tilde "$path"))"
1141
+ else fail "$label does not read it ($(tilde "$path") is not a link to it)"; hint "run: trailkeep install"; fi
1142
+ done
1143
+ has_content "$CODEX_DIR/AGENTS.override.md" && warn "$(tilde "$CODEX_DIR/AGENTS.override.md") overrides your personal file in Codex"
1144
+ say ""
1145
+ }
1146
+
1147
+ # The hook files call this script by absolute path; catch a moved clone.
1148
+ hook_target_ok() { # LABEL FILE
1149
+ p=$(sed -n "s/.*\"'\([^']*\)' context --for.*/\1/p" "$2" | head -n 1)
1150
+ if [ -n "$p" ] && [ ! -x "$p" ]; then
1151
+ fail "$1 hook points at $p, which no longer exists"
1152
+ hint "run trailkeep install from the clone's current location"
1153
+ fi
1154
+ }
1155
+
1156
+ doctor_hooks() {
1157
+ say "Session hooks"
1158
+ if [ -x "$RT_BIN" ]; then ok "runtime $(runtime_field version) at $(tilde "$RUNTIME") ($(runtime_field source))"
1159
+ else fail "runtime missing at $(tilde "$RUNTIME"), so every hook fails"; hint "run: trailkeep install"; fi
1160
+ if has claude; then
1161
+ if claude plugin list 2>/dev/null | grep -q 'trailkeep@trailkeep'; then ok "Claude Code plugin installed"
1162
+ else fail "Claude Code plugin not installed"; hint "run: trailkeep install"; fi
1163
+ fi
1164
+ if has codex || [ -d "$CODEX_DIR" ]; then
1165
+ if [ -f "$CODEX_DIR/hooks.json" ] && grep -q "trailkeep' context --for codex" "$CODEX_DIR/hooks.json"; then
1166
+ ok "Codex hook configured"
1167
+ grep -q "trailkeep' harvest --for codex" "$CODEX_DIR/hooks.json" || { warn "Codex session-end hook missing, decisions won't be logged"; hint "run: trailkeep install"; }
1168
+ hook_target_ok "Codex" "$CODEX_DIR/hooks.json"
1169
+ # Trust is stored in config.toml under a key like "file:<dir>/hooks.json:session_start:0:0".
1170
+ if [ -f "$CODEX_DIR/config.toml" ] && grep -qF 'hooks.json:session_start' "$CODEX_DIR/config.toml" && grep -qF 'hooks.json:session_end' "$CODEX_DIR/config.toml"; then
1171
+ ok "Codex hook has a trust entry in config.toml"
1172
+ hint "Codex re-checks trust against the hook's exact definition; after reinstalling from a new path, trust it again in /hooks"
1173
+ else
1174
+ warn "Codex hooks not trusted yet, so they will not run"
1175
+ hint "open codex, run /hooks, and trust both trailkeep hooks"
1176
+ fi
1177
+ else fail "Codex hook missing"; hint "run: trailkeep install"; fi
1178
+ fi
1179
+ if has copilot || [ -d "$COPILOT_DIR" ]; then
1180
+ if [ -f "$COPILOT_DIR/hooks/trailkeep.json" ]; then ok "Copilot hook configured"; hook_target_ok "Copilot" "$COPILOT_DIR/hooks/trailkeep.json"
1181
+ grep -q "harvest --for copilot" "$COPILOT_DIR/hooks/trailkeep.json" || { warn "Copilot session-end hook missing"; hint "run: trailkeep install"; }
1182
+ else fail "Copilot hook missing"; hint "run: trailkeep install"; fi
1183
+ fi
1184
+ if has trailkeep; then ok "trailkeep on PATH"; else warn "trailkeep is not on PATH (agents can't run it)"; fi
1185
+ say ""
1186
+ }
1187
+
1188
+ # Codex: global file + one file per directory from git root to cwd, combined
1189
+ # limit project_doc_max_bytes (default 32 KiB).
1190
+ # Codex: one file per directory from the git root down to cwd. The combined
1191
+ # project files share project_doc_max_bytes (32 KiB); the global file doesn't count.
1192
+ codex_chain() { # ROOT CWD -> prints project files in load order
1193
+ d=$1
1194
+ rel=${2#"$1"}
1195
+ printf '%s\n' "$d"
1196
+ printf '%s\n' "$rel" | tr '/' '\n' | while IFS= read -r part; do
1197
+ [ -n "$part" ] || continue
1198
+ d=$d/$part
1199
+ printf '%s\n' "$d"
1200
+ done
1201
+ }
1202
+
1203
+ doctor_repo() {
1204
+ in_repo || { info "not in a git repository: skipped repo checks"; say ""; return 0; }
1205
+ top=$(cd "$(git rev-parse --show-toplevel)" && pwd -P)
1206
+ here=$(pwd -P)
1207
+ say "Repository layer ($(tilde "$top"))"
1208
+
1209
+ # Files Claude Code loads from cwd up to / (the user-level ~/.claude/CLAUDE.md
1210
+ # doesn't count: it never blocks AGENTS.md). Newline-separated: paths may contain spaces.
1211
+ claude_files=; agents_files=
1212
+ d=$here
1213
+ while :; do
1214
+ for n in CLAUDE.md .claude/CLAUDE.md CLAUDE.local.md; do
1215
+ f=$d/$n
1216
+ [ -f "$f" ] || continue
1217
+ [ "$f" = "$CLAUDE_DIR/CLAUDE.md" ] && continue
1218
+ claude_files="$claude_files$f
1219
+ "
1220
+ done
1221
+ for n in AGENTS.md .claude/AGENTS.md; do [ -f "$d/$n" ] && agents_files="$agents_files$d/$n
1222
+ "; done
1223
+ [ "$d" = / ] && break
1224
+ d=$(dirname "$d")
1225
+ done
1226
+
1227
+ if [ -z "$claude_files$agents_files" ]; then
1228
+ info "no AGENTS.md or CLAUDE.md here; fine for small repos"
1229
+ hint "if you add one, write only what an agent can't learn from the code: exact commands, gotchas, decisions"
1230
+ fi
1231
+
1232
+ printf '%s' "$claude_files$agents_files" | while IFS= read -r f; do
1233
+ [ -n "$f" ] || continue
1234
+ case $f in "$top"/*) rel=${f#"$top"/} ;; *) rel=$(tilde "$f") ;; esac
1235
+ check_size "$rel" "$f"
1236
+ check_imports "$f" | while IFS= read -r p; do warn "$rel: '@$p' looks like an import but the file doesn't exist"; done
1237
+ if prose_only "$f" | grep -qE '(^|[[:space:]])@(~/|/)'; then
1238
+ info "$rel imports a ~/ or absolute path; Copilot CLI ignores those"
1239
+ fi
1240
+ done
1241
+
1242
+ # Claude Code reads AGENTS.md only when no CLAUDE.md exists (v2.1.277+).
1243
+ if [ -n "$claude_files" ] && [ -n "$agents_files" ]; then
1244
+ imported=$(printf '%s' "$claude_files" | while IFS= read -r c; do
1245
+ [ -n "$c" ] && prose_only "$c" | grep -qE '(^|[[:space:]])@(\./)?AGENTS\.md' && echo yes
1246
+ done)
1247
+ if [ -z "$imported" ]; then
1248
+ first=$(printf '%s' "$claude_files" | head -n 1)
1249
+ warn "$(tilde "$first") exists, so Claude Code ignores AGENTS.md here"
1250
+ hint "add '@AGENTS.md' to it, or delete it if it holds nothing Claude-specific"
1251
+ fi
1252
+ fi
1253
+ printf '%s' "$claude_files" | while IFS= read -r c; do
1254
+ [ -n "$c" ] || continue
1255
+ if [ "$(prose_only "$c" | awk 'NF' | grep -v '^[[:space:]]*<!--.*-->[[:space:]]*$')" = "@AGENTS.md" ]; then
1256
+ info "$(tilde "$c") only imports AGENTS.md; Claude Code 2.1.277+ reads AGENTS.md itself, so it can go"
1257
+ fi
1258
+ done
1259
+
1260
+ total=0
1261
+ chain=$(codex_chain "$top" "$here")
1262
+ # (a subshell-free loop so $total survives)
1263
+ oldifs=$IFS; IFS='
1264
+ '
1265
+ for dir in $chain; do
1266
+ for n in AGENTS.override.md AGENTS.md; do
1267
+ if [ -s "$dir/$n" ]; then total=$((total + $(chars "$dir/$n"))); break; fi
1268
+ done
1269
+ done
1270
+ IFS=$oldifs
1271
+ if [ "$total" -gt 32768 ]; then
1272
+ fail "Codex project instructions are $total bytes; Codex stops at 32768 (project_doc_max_bytes), the rest is dropped"
1273
+ elif [ "$total" -gt 24576 ]; then
1274
+ warn "Codex project instructions are $total bytes, close to the 32768 limit"
1275
+ elif [ "$total" -gt 0 ]; then
1276
+ ok "Codex project instructions: $total of 32768 bytes"
1277
+ fi
1278
+ say ""
1279
+ }
1280
+
1281
+ doctor_task() {
1282
+ in_repo || return 0
1283
+ KEY=$(repo_key) || return 0
1284
+ say "Task layer (key: $KEY)"
1285
+ n=$(notes_path "$KEY")
1286
+ if [ -s "$n" ]; then ok "private notes: $(chars "$n") chars"; else info "no private notes (trailkeep notes)"; fi
1287
+ d=$(effort_dir)
1288
+ if [ -d "$d" ] && [ -n "$(git -C "$(worktree_top)" status --porcelain --ignored=no -- .trailkeep 2>/dev/null)" ]; then
1289
+ fail ".trailkeep/ is not ignored by git, so efforts could get committed"
1290
+ hint "restore $(tilde "$d/.gitignore") with a single line: *"
1291
+ fi
1292
+ if b=$(current_branch) && [ -n "$b" ]; then
1293
+ e=$(effort_path "$KEY" "$b")
1294
+ if [ -s "$e" ]; then ok "effort for $b: $(lines "$e") lines, $(chars "$e") chars"
1295
+ else info "no effort for branch $b (trailkeep new)"; fi
1296
+ else info "detached HEAD: no effort lookup"; fi
1297
+ out=$(build_context 2>/dev/null)
1298
+ case $out in *"over the trailkeep budget"*)
1299
+ warn "notes or effort are too big to inject, so agents only get a pointer to them"
1300
+ hint "fold old Progress lines into a summary; notes get up to $((MAX_CHARS / 3)) chars, the effort the rest of $MAX_CHARS" ;;
1301
+ esac
1302
+ n=0; for f in "$(log_dir "$KEY")"/*.tsv; do [ -f "$f" ] && n=$((n + $(lines "$f"))); done
1303
+ if [ "$n" -gt 0 ]; then ok "decision log: $n entries (trailkeep log)"; else info "decision log is empty (it fills from effort files at session end)"; fi
1304
+ gone=$(gone_efforts | awk 'NF' | wc -l | tr -d ' ')
1305
+ if [ "$gone" -gt 0 ]; then warn "$gone effort(s) belong to deleted branches"; hint "trailkeep prune --apply"; fi
1306
+ say ""
1307
+ }
1308
+
1309
+ cmd_doctor() {
1310
+ say "trailkeep $VERSION doctor"
1311
+ say ""
1312
+ doctor_personal
1313
+ doctor_hooks
1314
+ doctor_repo
1315
+ doctor_task
1316
+ if [ "$FAILS" -gt 0 ]; then say "$FAILS problem(s), $WARNS warning(s)."; exit 1; fi
1317
+ say "No problems. $WARNS warning(s)."
1318
+ }
1319
+
1320
+ # ---------------------------------------------------------------- verify ----
1321
+ # Live end-to-end test: plants a random marker in each layer, asks each agent
1322
+ # which markers it can see without using tools, then restores everything.
1323
+
1324
+ rand() { od -An -N4 -tu4 /dev/urandom | tr -d ' \n'; }
1325
+
1326
+ cmd_verify() {
1327
+ agents="claude codex copilot"
1328
+ [ "${1:-}" = --agents ] && { agents=$(printf '%s' "${2:-}" | tr ',' ' '); }
1329
+ [ -f "$PERSONAL" ] || die "no personal file yet: run trailkeep install"
1330
+
1331
+ id=$(rand)
1332
+ P=ACX-P$id; R=ACX-R$id; N=ACX-N$id; E=ACX-E$id; D=ACX-D$id
1333
+ work=$(mktemp -d) || die "mktemp failed"
1334
+ repo=$work/trailkeep-verify-$id
1335
+ personal_backup=$work/personal.bak
1336
+ cp "$PERSONAL" "$personal_backup" || die "could not back up $PERSONAL"
1337
+ vkey=
1338
+
1339
+ cleanup() {
1340
+ trap - EXIT INT TERM HUP
1341
+ # Restore only from a complete backup; never truncate the real file.
1342
+ if [ -f "$personal_backup" ]; then cp "$personal_backup" "$PERSONAL"; fi
1343
+ if [ -n "$vkey" ]; then rm -rf "${REPOS:?}/$vkey"; fi
1344
+ rm -rf "$work"
1345
+ }
1346
+ trap cleanup EXIT
1347
+ trap 'cleanup; exit 130' INT TERM HUP
1348
+
1349
+ mkdir -p "$repo"
1350
+ ( cd "$repo" && git init -q && git symbolic-ref HEAD refs/heads/trailkeep-verify ) || die "git init failed"
1351
+ printf '# Test repo\n\nVerification marker: %s\n' "$R" > "$repo/AGENTS.md"
1352
+ printf '\nVerification marker: %s\n' "$P" >> "$PERSONAL"
1353
+ vkey=$(cd "$repo" && repo_key)
1354
+ mkdir -p "$REPOS/$vkey" "$repo/.trailkeep"
1355
+ printf '*\n' > "$repo/.trailkeep/.gitignore"
1356
+ printf '# Notes\n\nVerification marker: %s\n' "$N" > "$REPOS/$vkey/NOTES.md"
1357
+ printf '# trailkeep-verify\n\nBranch: trailkeep-verify · Repo: %s · Kind: branch · Created: %s\n\n## Goal\n\nVerification marker: %s\n\n## Decisions and dead ends\n\n- verify decision %s\n' "$vkey" "$(today)" "$E" "$D" > "$repo/.trailkeep/trailkeep-verify.md"
1358
+
1359
+ q='Without using any tools, reading files or running commands, list every token that starts with "ACX-" that appears anywhere in your instructions or context. Reply with the tokens separated by spaces and nothing else. If there are none, reply NONE.'
1360
+
1361
+ say "trailkeep verify: asking each agent which layers it can see (a few hundred tokens each)"
1362
+ say ""
1363
+ printf ' %-9s %-10s %-10s %-10s %-10s %-10s\n' agent personal repo notes effort logged
1364
+ for a in $agents; do
1365
+ if ! has "$a"; then printf ' %-9s %s\n' "$a" "not installed, skipped"; continue; fi
1366
+ out=$work/$a.out
1367
+ case $a in
1368
+ claude) ( cd "$repo" && claude -p "$q" --tools "" --no-session-persistence ) > "$out" 2>&1 ;;
1369
+ codex) ( cd "$repo" && codex exec --ephemeral --skip-git-repo-check -s read-only "$q" ) > "$out" 2>&1 ;;
1370
+ copilot) ( cd "$repo" && copilot -p "$q" -s --no-ask-user ) > "$out" 2>&1 ;;
1371
+ *) printf ' %-9s %s\n' "$a" "unknown agent"; continue ;;
1372
+ esac
1373
+ row=
1374
+ for m in "$P" "$R" "$N" "$E"; do
1375
+ if grep -q "$m" "$out"; then row="$row$(printf '%-10s ' yes)"; else row="$row$(printf '%-10s ' NO)"; FAILS=$((FAILS + 1)); eval "miss_$a=1"; fi
1376
+ done
1377
+ # The session-end (or next session-start) hook should have logged the decision.
1378
+ if cat "$REPOS/$vkey/log/"*.tsv 2>/dev/null | grep -q "$D"; then row="$row$(printf '%-10s ' yes)"
1379
+ else row="$row$(printf '%-10s ' NO)"; FAILS=$((FAILS + 1)); eval "miss_$a=1"; fi
1380
+ rm -rf "$REPOS/$vkey/log"
1381
+ printf ' %-9s %s\n' "$a" "$row"
1382
+ done
1383
+ say ""
1384
+ for a in $agents; do
1385
+ eval "m=\${miss_$a:-0}"
1386
+ [ "$m" -eq 1 ] || continue
1387
+ say "$a missed something. Its reply:"
1388
+ tail -n 5 "$work/$a.out" | sed 's/^/ /'
1389
+ case $a in
1390
+ claude) hint "notes/effort/logged missing: check 'claude plugin list' shows trailkeep@trailkeep enabled" ;;
1391
+ codex) hint "notes/effort/logged missing: the hooks are probably untrusted; open codex, run /hooks, trust both" ;;
1392
+ copilot) hint "personal missing: Copilot may not follow the symlink; set COPILOT_CUSTOM_INSTRUCTIONS_DIRS=$(tilde "$TRAILKEEP_HOME") in your shell rc" ;;
1393
+ esac
1394
+ say ""
1395
+ done
1396
+ if [ "$FAILS" -eq 0 ]; then say "Every agent sees every layer."; else exit 1; fi
1397
+ }
1398
+
1399
+ # ------------------------------------------------------------------ main ----
1400
+
1401
+ usage() {
1402
+ cat <<EOF
1403
+ trailkeep $VERSION: global knowledge + per-branch task context for coding agents
1404
+
1405
+ Setup
1406
+ install [--adopt] [--dry-run] [--no-claude|--no-codex|--no-copilot] [--no-skill]
1407
+ update git pull this clone, then re-run install
1408
+ uninstall [--dry-run]
1409
+ doctor check the setup and the current repo's instruction files
1410
+ verify [--agents claude,codex,copilot]
1411
+ live test: ask each agent which layers it sees
1412
+
1413
+ Per repo / branch (run inside a git repo)
1414
+ new create the effort file for the current branch
1415
+ show print what agents get at session start here
1416
+ edit open the effort file in \$EDITOR
1417
+ path [--notes|--repo|--dir]
1418
+ notes [--path] open (or print the path of) your private notes for this repo
1419
+ close archive this branch's effort and list decisions to promote
1420
+ prune [--apply] archive efforts whose branches no longer exist
1421
+ list all active efforts across repos
1422
+ log [--path P] [--grep TEXT] [--type T] [--branch B] [--since DATE] [-n N]
1423
+ log show ID one decision log entry in full
1424
+ log add TYPE TEXT add an entry by hand
1425
+
1426
+ Internal
1427
+ context --for claude|codex|copilot|text (session-start hook)
1428
+ harvest --for claude|codex|copilot (session-end hook)
1429
+
1430
+ Data: $(tilde "$TRAILKEEP_HOME") (override with TRAILKEEP_HOME)
1431
+ EOF
1432
+ }
1433
+
1434
+ cmd=${1:-help}
1435
+ [ $# -gt 0 ] && shift
1436
+ case $cmd in
1437
+ install) cmd_install "$@" ;;
1438
+ uninstall) cmd_uninstall "$@" ;;
1439
+ doctor) cmd_doctor "$@" ;;
1440
+ verify) cmd_verify "$@" ;;
1441
+ new) cmd_new "$@" ;;
1442
+ show) cmd_show "$@" ;;
1443
+ edit) cmd_edit "$@" ;;
1444
+ path) cmd_path "$@" ;;
1445
+ notes) cmd_notes "$@" ;;
1446
+ close) cmd_close "$@" ;;
1447
+ prune) cmd_prune "$@" ;;
1448
+ list) cmd_list "$@" ;;
1449
+ context) cmd_context "$@" ;;
1450
+ harvest) cmd_harvest "$@" ;;
1451
+ log) cmd_log "$@" ;;
1452
+ update) cmd_update "$@" ;;
1453
+ version|--version|-V) say "trailkeep $VERSION" ;;
1454
+ help|--help|-h) usage ;;
1455
+ *) usage >&2; exit 2 ;;
1456
+ esac