@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/.claude-plugin/marketplace.json +11 -0
- package/.claude-plugin/plugin.json +8 -0
- package/LICENSE +21 -0
- package/README.md +152 -2
- package/bin/trailkeep +1456 -0
- package/hooks/hooks.json +28 -0
- package/package.json +42 -4
- package/skills/effort/SKILL.md +59 -0
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
|