create-egregore 0.19.7 → 0.19.11
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/assets/egregore-launcher.js +188 -295
- package/lib/local.js +11 -1
- package/lib/setup.js +33 -0
- package/package.json +1 -1
- package/runtime/codex/.codex/hooks/branch-guard.js +242 -34
- package/runtime/codex/.codex/skills/activity/SKILL.md +18 -6
- package/runtime/codex/.codex/skills/deep-reflect/SKILL.md +90 -55
- package/runtime/codex/.codex/skills/deploy-bridge/SKILL.md +25 -0
- package/runtime/codex/.codex/skills/handoff/SKILL.md +6 -2
- package/runtime/codex/.codex/skills/save/SKILL.md +21 -3
- package/runtime/codex/.codex/skills/scroll/SKILL.md +54 -3
- package/runtime/codex/.codex/skills/task/SKILL.md +25 -0
- package/runtime/codex/.codex/skills/tui-design/SKILL.md +1 -1
- package/runtime/codex/.codex/skills/wrap/SKILL.md +4 -1
- package/runtime/codex/.codex/spec-manifest.json +15 -3
- package/runtime/codex/AGENTS.md +45 -9
- package/runtime/codex/bin/activity-data.sh +1 -1
- package/runtime/codex/bin/agent.sh +66 -23
- package/runtime/codex/bin/attendant.sh +525 -0
- package/runtime/codex/bin/capture-reconcile.sh +67 -0
- package/runtime/codex/bin/capture-run.sh +341 -0
- package/runtime/codex/bin/codex-render-spec.mjs +15 -4
- package/runtime/codex/bin/codex-session-start.sh +45 -3
- package/runtime/codex/bin/codex-skill-render.mjs +23 -4
- package/runtime/codex/bin/graph-op.sh +719 -49
- package/runtime/codex/bin/graph-wal.sh +3 -2
- package/runtime/codex/bin/handoff-run.sh +72 -0
- package/runtime/codex/bin/handoff-save-egregore.sh +51 -11
- package/runtime/codex/bin/index-handoff.sh +26 -17
- package/runtime/codex/bin/ingest-graph.sh +80 -0
- package/runtime/codex/bin/ingest-surface.html +429 -0
- package/runtime/codex/bin/ingest.py +614 -0
- package/runtime/codex/bin/ingest.sh +231 -0
- package/runtime/codex/bin/ingest_graph.py +776 -0
- package/runtime/codex/bin/ingest_surface.py +464 -0
- package/runtime/codex/bin/lib/git-sync.sh +2 -6
- package/runtime/codex/bin/lib/greeting.sh +157 -22
- package/runtime/codex/bin/lib/identity.sh +50 -29
- package/runtime/codex/bin/lib/noncode.sh +31 -0
- package/runtime/codex/bin/person.py +610 -0
- package/runtime/codex/bin/person.sh +448 -0
- package/runtime/codex/bin/session-autosave.sh +184 -0
- package/runtime/codex/bin/session-log.sh +199 -0
- package/runtime/codex/bin/settings.sh +1 -1
- package/runtime/codex/manifest.json +19 -1
- package/runtime/pi/.codex/hooks/branch-guard.js +242 -34
- package/runtime/pi/.codex/skills/activity/SKILL.md +18 -6
- package/runtime/pi/.codex/skills/deep-reflect/SKILL.md +90 -55
- package/runtime/pi/.codex/skills/deploy-bridge/SKILL.md +25 -0
- package/runtime/pi/.codex/skills/handoff/SKILL.md +6 -2
- package/runtime/pi/.codex/skills/save/SKILL.md +21 -3
- package/runtime/pi/.codex/skills/scroll/SKILL.md +54 -3
- package/runtime/pi/.codex/skills/task/SKILL.md +25 -0
- package/runtime/pi/.codex/skills/tui-design/SKILL.md +1 -1
- package/runtime/pi/.codex/skills/wrap/SKILL.md +4 -1
- package/runtime/pi/.codex/spec-manifest.json +15 -3
- package/runtime/pi/.pi/APPEND_SYSTEM.md +41 -5
- package/runtime/pi/.pi/extensions/egregore.ts +98 -8
- package/runtime/pi/.pi/spec-manifest.json +2 -2
- package/runtime/pi/AGENTS.md +45 -9
- package/runtime/pi/bin/activity-data.sh +1 -1
- package/runtime/pi/bin/agent.sh +66 -23
- package/runtime/pi/bin/attendant.sh +525 -0
- package/runtime/pi/bin/capture-reconcile.sh +67 -0
- package/runtime/pi/bin/capture-run.sh +341 -0
- package/runtime/pi/bin/codex-render-spec.mjs +15 -4
- package/runtime/pi/bin/codex-session-start.sh +45 -3
- package/runtime/pi/bin/codex-skill-render.mjs +23 -4
- package/runtime/pi/bin/graph-op.sh +719 -49
- package/runtime/pi/bin/graph-wal.sh +3 -2
- package/runtime/pi/bin/handoff-run.sh +72 -0
- package/runtime/pi/bin/handoff-save-egregore.sh +51 -11
- package/runtime/pi/bin/index-handoff.sh +26 -17
- package/runtime/pi/bin/ingest-graph.sh +80 -0
- package/runtime/pi/bin/ingest-surface.html +429 -0
- package/runtime/pi/bin/ingest.py +614 -0
- package/runtime/pi/bin/ingest.sh +231 -0
- package/runtime/pi/bin/ingest_graph.py +776 -0
- package/runtime/pi/bin/ingest_surface.py +464 -0
- package/runtime/pi/bin/lib/git-sync.sh +2 -6
- package/runtime/pi/bin/lib/greeting.sh +157 -22
- package/runtime/pi/bin/lib/identity.sh +50 -29
- package/runtime/pi/bin/lib/noncode.sh +31 -0
- package/runtime/pi/bin/person.py +610 -0
- package/runtime/pi/bin/person.sh +448 -0
- package/runtime/pi/bin/pi-branch-policy.mjs +164 -4
- package/runtime/pi/bin/pi-product-workflows.mjs +31 -2
- package/runtime/pi/bin/publish-artifact.sh +14 -0
- package/runtime/pi/bin/session-autosave.sh +184 -0
- package/runtime/pi/bin/session-log.sh +199 -0
- package/runtime/pi/bin/settings.sh +274 -0
- package/runtime/pi/manifest.json +20 -1
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# The attendant — Egregore's ambient listener. A per-instance daemon that
|
|
3
|
+
# watches for work Claude Code sessions leave behind when they die without
|
|
4
|
+
# ceremony (terminal closed, laptop shut) and captures it:
|
|
5
|
+
#
|
|
6
|
+
# ripcord dead session + no handoff → deterministic auto-handoff to
|
|
7
|
+
# memory/handoffs/ (kind: ripcord), assembled from the session
|
|
8
|
+
# transcript + git state. No model, no API calls.
|
|
9
|
+
# auto-push dirty/unpushed dev|feature|bugfix branches in this repo's
|
|
10
|
+
# worktrees, once no session is active → commit + push the
|
|
11
|
+
# branch. Never merges, never touches develop/main.
|
|
12
|
+
#
|
|
13
|
+
# In-harness hooks (SessionEnd, PreCompact) die with the process; the
|
|
14
|
+
# attendant lives outside it. The signal is Claude Code's own transcript
|
|
15
|
+
# files (~/.claude/projects/<key>/*.jsonl), written continuously regardless
|
|
16
|
+
# of how a session ends: transcript gone stale + work on disk = pull the cord.
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# bash bin/attendant.sh ensure # spawn daemon if not running (fire-and-forget from session-start)
|
|
20
|
+
# bash bin/attendant.sh run # daemon loop (foreground)
|
|
21
|
+
# bash bin/attendant.sh sweep # single pass (tests, cron)
|
|
22
|
+
# bash bin/attendant.sh status # is it alive + last log lines
|
|
23
|
+
# bash bin/attendant.sh stop # kill the daemon
|
|
24
|
+
#
|
|
25
|
+
# Env overrides (mainly for tests):
|
|
26
|
+
# ATTENDANT_STALE_MIN minutes of transcript silence = dead session (default 12)
|
|
27
|
+
# ATTENDANT_DRY_RUN=1 log actions, mutate nothing
|
|
28
|
+
# ATTENDANT_HOME state dir (default ~/.egregore/attendant)
|
|
29
|
+
# CLAUDE_PROJECTS_DIR transcript root (default ~/.claude/projects)
|
|
30
|
+
# EGREGORE_MEMORY_DIR memory repo (default <repo>/memory)
|
|
31
|
+
|
|
32
|
+
set -uo pipefail
|
|
33
|
+
|
|
34
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
35
|
+
MAIN_DIR="$SCRIPT_DIR"
|
|
36
|
+
if [ -f "$SCRIPT_DIR/.git" ]; then
|
|
37
|
+
WT_GITDIR=$(sed 's/^gitdir: //' "$SCRIPT_DIR/.git" 2>/dev/null || true)
|
|
38
|
+
[ -n "$WT_GITDIR" ] && MAIN_DIR=$(cd "$WT_GITDIR/../../.." 2>/dev/null && pwd || echo "$SCRIPT_DIR")
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
CONFIG="$MAIN_DIR/egregore.json"
|
|
42
|
+
MEMORY_DIR="${EGREGORE_MEMORY_DIR:-$MAIN_DIR/memory}"
|
|
43
|
+
STALE_MIN="${ATTENDANT_STALE_MIN:-12}"
|
|
44
|
+
MAX_AGE_MIN=1440 # ignore transcripts older than 24h
|
|
45
|
+
RIPCORD_MIN_BYTES=20000 # skip trivial sessions
|
|
46
|
+
DRY_RUN="${ATTENDANT_DRY_RUN:-0}"
|
|
47
|
+
CLAUDE_PROJECTS="${CLAUDE_PROJECTS_DIR:-$HOME/.claude/projects}"
|
|
48
|
+
|
|
49
|
+
SLUG=$(jq -r '.slug // "egregore"' "$CONFIG" 2>/dev/null || echo "egregore")
|
|
50
|
+
KEY="${SLUG}-$(echo -n "$MAIN_DIR" | cksum | cut -d' ' -f1)"
|
|
51
|
+
STATE_DIR="${ATTENDANT_HOME:-$HOME/.egregore/attendant}"
|
|
52
|
+
PIDFILE="$STATE_DIR/$KEY.pid"
|
|
53
|
+
VERSIONFILE="$STATE_DIR/$KEY.version"
|
|
54
|
+
LOGFILE="$STATE_DIR/$KEY.log"
|
|
55
|
+
JOURNAL="$STATE_DIR/$KEY.journal"
|
|
56
|
+
mkdir -p "$STATE_DIR"
|
|
57
|
+
|
|
58
|
+
# Munged project key, same rule Claude Code uses for ~/.claude/projects dirs.
|
|
59
|
+
PROJ_KEY=$(echo -n "$MAIN_DIR" | sed 's|[^A-Za-z0-9]|-|g')
|
|
60
|
+
|
|
61
|
+
log() {
|
|
62
|
+
echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOGFILE"
|
|
63
|
+
# keep the log bounded
|
|
64
|
+
if [ "$(wc -c < "$LOGFILE" 2>/dev/null | tr -d ' ')" -gt 1000000 ]; then
|
|
65
|
+
tail -500 "$LOGFILE" > "$LOGFILE.tmp" && mv "$LOGFILE.tmp" "$LOGFILE"
|
|
66
|
+
fi
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_script_version() {
|
|
70
|
+
cksum "$MAIN_DIR/bin/attendant.sh" 2>/dev/null | awk '{print $1 ":" $2}'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_enabled() {
|
|
74
|
+
[ "$(jq -r 'if .features.attendant == false then "false" else "true" end' "$CONFIG" 2>/dev/null)" != "false" ]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_weaver_enabled() {
|
|
78
|
+
[ "$(jq -r 'if .features.weaver == false then "false" else "true" end' "$CONFIG" 2>/dev/null)" != "false" ]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_trigger_auto_thread() {
|
|
82
|
+
local reason="$1"
|
|
83
|
+
local runner="$MAIN_DIR/threads/bin/auto-thread.sh"
|
|
84
|
+
local dry_arg=""
|
|
85
|
+
_weaver_enabled || return 0
|
|
86
|
+
[ -f "$runner" ] || return 0
|
|
87
|
+
[ "$DRY_RUN" = "1" ] && dry_arg="--dry-run"
|
|
88
|
+
|
|
89
|
+
# Projection is downstream of capture. Run it detached so a slow or broken
|
|
90
|
+
# renderer can never delay ripcord persistence or branch auto-save. The
|
|
91
|
+
# runner's atomic lock coalesces ripcord + end-of-sweep triggers.
|
|
92
|
+
(
|
|
93
|
+
if ! bash "$runner" \
|
|
94
|
+
--repo-dir "$MAIN_DIR" \
|
|
95
|
+
--memory-dir "$MEMORY_DIR" \
|
|
96
|
+
${dry_arg:+"$dry_arg"}; then
|
|
97
|
+
log "auto-thread failed ($reason)"
|
|
98
|
+
fi
|
|
99
|
+
) >> "$LOGFILE" 2>&1 &
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_author() {
|
|
103
|
+
local a
|
|
104
|
+
a=$(jq -r '.github_username // empty' "$MAIN_DIR/.egregore-state.json" 2>/dev/null)
|
|
105
|
+
[ -z "$a" ] && a=$(git -C "$MAIN_DIR" config user.name 2>/dev/null | tr ' ' '-' | tr '[:upper:]' '[:lower:]')
|
|
106
|
+
echo "${a:-unknown}"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# --- Session liveness ------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
_transcript_dirs() {
|
|
112
|
+
find "$CLAUDE_PROJECTS" -maxdepth 1 -type d -name "${PROJ_KEY}*" 2>/dev/null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_active_session() {
|
|
116
|
+
local d
|
|
117
|
+
for d in $(_transcript_dirs); do
|
|
118
|
+
if [ -n "$(find "$d" -maxdepth 1 -name '*.jsonl' -mmin "-$STALE_MIN" 2>/dev/null | head -1)" ]; then
|
|
119
|
+
return 0
|
|
120
|
+
fi
|
|
121
|
+
done
|
|
122
|
+
return 1
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_stale_transcripts() {
|
|
126
|
+
local d
|
|
127
|
+
for d in $(_transcript_dirs); do
|
|
128
|
+
find "$d" -maxdepth 1 -name '*.jsonl' -mmin "+$STALE_MIN" -mmin "-$MAX_AGE_MIN" -size "+${RIPCORD_MIN_BYTES}c" 2>/dev/null
|
|
129
|
+
done
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# --- Ripcord ---------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
_last_prompts() {
|
|
135
|
+
# Last few human prompts from a transcript, cleaned of harness noise.
|
|
136
|
+
jq -r 'select(.type=="user") | .message.content
|
|
137
|
+
| if type=="string" then .
|
|
138
|
+
else ([.[]? | select(.type?=="text") | .text] | join(" ")) end' "$1" 2>/dev/null \
|
|
139
|
+
| grep -v -e '^\s*$' -e 'system-reminder' -e '<command-' -e 'tool_use_error' -e '^Caveat:' -e '<task-notification' \
|
|
140
|
+
-e '<local-command-caveat' -e '</local-command-caveat>' -e '<bash-input>' -e '<bash-stdout>' -e '<bash-stderr>' \
|
|
141
|
+
| tail -5 | cut -c1-300
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_slugify() {
|
|
145
|
+
printf '%s' "$1" \
|
|
146
|
+
| tr '[:upper:]' '[:lower:]' \
|
|
147
|
+
| sed 's/<[^>]*>/ /g; s/[`*_#|:;,.!?()[\]{}"'"'"']/ /g; s/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//' \
|
|
148
|
+
| cut -c1-72 \
|
|
149
|
+
| sed 's/-$//'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_prompt_topic_slug() {
|
|
153
|
+
local prompts="$1"
|
|
154
|
+
local line clean lower slug score best_score=0 best_slug=""
|
|
155
|
+
|
|
156
|
+
while IFS= read -r line; do
|
|
157
|
+
[ -z "$line" ] && continue
|
|
158
|
+
lower=$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')
|
|
159
|
+
case "$lower" in
|
|
160
|
+
start|*request\ interrupted*|*system-reminder*|*local-command-caveat*|*bash-input*|*bash-stdout*|*bash-stderr*|*tool_use_error*)
|
|
161
|
+
continue
|
|
162
|
+
;;
|
|
163
|
+
esac
|
|
164
|
+
|
|
165
|
+
clean=$(printf '%s' "$line" | sed 's/^>[[:space:]]*//; s/<[^>]*>/ /g; s/[|`*_#]/ /g')
|
|
166
|
+
slug=$(_slugify "$clean")
|
|
167
|
+
[ -z "$slug" ] && continue
|
|
168
|
+
[ "${#slug}" -lt 8 ] && continue
|
|
169
|
+
|
|
170
|
+
score=${#slug}
|
|
171
|
+
case "$slug" in
|
|
172
|
+
*handoff*|*emissary*|*harvest*|*ssh*|*pr*|*review*|*egregore*|*invite*|*deploy*|*release*)
|
|
173
|
+
score=$((score + 20))
|
|
174
|
+
;;
|
|
175
|
+
esac
|
|
176
|
+
|
|
177
|
+
if [ "$score" -gt "$best_score" ]; then
|
|
178
|
+
best_score="$score"
|
|
179
|
+
best_slug="$slug"
|
|
180
|
+
fi
|
|
181
|
+
done <<EOF
|
|
182
|
+
$prompts
|
|
183
|
+
EOF
|
|
184
|
+
|
|
185
|
+
printf '%s\n' "$best_slug"
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_branch_topic_slug() {
|
|
189
|
+
local gitsum="$1"
|
|
190
|
+
local author="$2"
|
|
191
|
+
local branch rest score best_score=0 best_slug=""
|
|
192
|
+
|
|
193
|
+
while IFS= read -r branch; do
|
|
194
|
+
[ -z "$branch" ] && continue
|
|
195
|
+
case "$branch" in
|
|
196
|
+
develop|main|master) continue ;;
|
|
197
|
+
dev/"$author"/*) rest="${branch#dev/$author/}"; score=90 ;;
|
|
198
|
+
feature/*|bugfix/*|hotfix/*) rest="${branch#*/}"; score=80 ;;
|
|
199
|
+
dev/*/*) rest="${branch#dev/}"; rest="${rest#*/}"; score=60 ;;
|
|
200
|
+
*/*) rest="${branch#*/}"; score=50 ;;
|
|
201
|
+
*) rest="$branch"; score=40 ;;
|
|
202
|
+
esac
|
|
203
|
+
|
|
204
|
+
rest=$(_slugify "$rest")
|
|
205
|
+
[ -z "$rest" ] && continue
|
|
206
|
+
if [ "$score" -gt "$best_score" ]; then
|
|
207
|
+
best_score="$score"
|
|
208
|
+
best_slug="$rest"
|
|
209
|
+
fi
|
|
210
|
+
done <<EOF
|
|
211
|
+
$(printf '%s\n' "$gitsum" | awk -F'`' '/^- `/{print $2}')
|
|
212
|
+
EOF
|
|
213
|
+
|
|
214
|
+
printf '%s\n' "$best_slug"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_ripcord_topic_slug() {
|
|
218
|
+
local prompts="$1"
|
|
219
|
+
local gitsum="$2"
|
|
220
|
+
local author="$3"
|
|
221
|
+
local slug
|
|
222
|
+
|
|
223
|
+
slug=$(_prompt_topic_slug "$prompts")
|
|
224
|
+
[ -z "$slug" ] && slug=$(_branch_topic_slug "$gitsum" "$author")
|
|
225
|
+
[ -z "$slug" ] && slug="session"
|
|
226
|
+
printf '%s\n' "$slug"
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_topic_from_slug() {
|
|
230
|
+
printf '%s\n' "$1" | tr '-' ' '
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
_git_summary() {
|
|
234
|
+
# Branch + dirty/unpushed state for the main checkout and every worktree.
|
|
235
|
+
local wt branch dirty ahead
|
|
236
|
+
git -C "$MAIN_DIR" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | while read -r wt; do
|
|
237
|
+
branch=$(git -C "$wt" branch --show-current 2>/dev/null)
|
|
238
|
+
[ -z "$branch" ] && continue
|
|
239
|
+
dirty=$(git -C "$wt" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
240
|
+
ahead=$(git -C "$wt" rev-list --count '@{u}..HEAD' 2>/dev/null || echo "?")
|
|
241
|
+
if [ "$dirty" != "0" ] || { [ "$ahead" != "0" ] && [ "$ahead" != "?" ]; }; then
|
|
242
|
+
echo "- \`$branch\` — $dirty uncommitted, $ahead unpushed ($(basename "$wt"))"
|
|
243
|
+
git -C "$wt" status --porcelain 2>/dev/null | head -8 | sed 's/^/ /'
|
|
244
|
+
fi
|
|
245
|
+
done
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
_ripcord() {
|
|
249
|
+
local transcript="$1" base author date_ym date_d slug topic file prompts gitsum
|
|
250
|
+
base=$(basename "$transcript" .jsonl)
|
|
251
|
+
|
|
252
|
+
grep -q "$base" "$JOURNAL" 2>/dev/null && return 0
|
|
253
|
+
# Session already ended with ceremony? Then the attendant has no business
|
|
254
|
+
# here. Scan only the tail — a session that merely *read* the /handoff or
|
|
255
|
+
# /save skill text mid-way would otherwise false-positive.
|
|
256
|
+
if tail -c 300000 "$transcript" 2>/dev/null | grep -q -e 'handoff-run.sh' -e 'wraps/'; then
|
|
257
|
+
echo "ripcord-skip $base (explicit handoff/wrap)" >> "$JOURNAL"
|
|
258
|
+
return 0
|
|
259
|
+
fi
|
|
260
|
+
|
|
261
|
+
author=$(_author)
|
|
262
|
+
prompts=$(_last_prompts "$transcript")
|
|
263
|
+
gitsum=$(_git_summary)
|
|
264
|
+
|
|
265
|
+
# Nothing on disk and nothing said — not worth a handoff.
|
|
266
|
+
if [ -z "$gitsum" ] && [ -z "$prompts" ]; then
|
|
267
|
+
echo "ripcord-skip $base (no work found)" >> "$JOURNAL"
|
|
268
|
+
return 0
|
|
269
|
+
fi
|
|
270
|
+
|
|
271
|
+
slug=$(_ripcord_topic_slug "$prompts" "$gitsum" "$author")
|
|
272
|
+
topic=$(_topic_from_slug "$slug")
|
|
273
|
+
|
|
274
|
+
date_ym=$(date '+%Y-%m'); date_d=$(date '+%d')
|
|
275
|
+
file="$MEMORY_DIR/handoffs/$date_ym/${date_d}-${author}-ripcord-${slug}.md"
|
|
276
|
+
|
|
277
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
278
|
+
log "DRY ripcord → $file"
|
|
279
|
+
_trigger_auto_thread "ripcord"
|
|
280
|
+
return 0
|
|
281
|
+
fi
|
|
282
|
+
|
|
283
|
+
mkdir -p "$(dirname "$file")"
|
|
284
|
+
{
|
|
285
|
+
echo "---"
|
|
286
|
+
echo "date: $(date '+%Y-%m-%d')"
|
|
287
|
+
echo "author: $author"
|
|
288
|
+
echo "to: $author"
|
|
289
|
+
echo "kind: ripcord"
|
|
290
|
+
echo "status: draft"
|
|
291
|
+
echo "topic: $topic"
|
|
292
|
+
echo "---"
|
|
293
|
+
echo ""
|
|
294
|
+
echo "# Ripcord: $topic"
|
|
295
|
+
echo ""
|
|
296
|
+
echo "Automatic capture — this session ended without an explicit handoff."
|
|
297
|
+
echo "Assembled by the attendant from the flight recorder (transcript + git state)."
|
|
298
|
+
echo ""
|
|
299
|
+
if [ -n "$gitsum" ]; then
|
|
300
|
+
echo "## Where the work lives"
|
|
301
|
+
echo ""
|
|
302
|
+
echo "$gitsum"
|
|
303
|
+
echo ""
|
|
304
|
+
fi
|
|
305
|
+
if [ -n "$prompts" ]; then
|
|
306
|
+
echo "## Last things the user asked for"
|
|
307
|
+
echo ""
|
|
308
|
+
echo "$prompts" | sed 's/^/> /'
|
|
309
|
+
echo ""
|
|
310
|
+
fi
|
|
311
|
+
echo "## Pick it up"
|
|
312
|
+
echo ""
|
|
313
|
+
echo "Check out the branch above and run \`/activity\` for surrounding context."
|
|
314
|
+
echo "Session transcript id: \`$base\` ($(date -r "$transcript" '+%Y-%m-%d %H:%M' 2>/dev/null))"
|
|
315
|
+
} > "$file"
|
|
316
|
+
|
|
317
|
+
# Commit + push memory (retry for concurrent pushers; tolerate no remote).
|
|
318
|
+
local rel="${file#"$MEMORY_DIR"/}"
|
|
319
|
+
git -C "$MEMORY_DIR" add "$rel" 2>/dev/null
|
|
320
|
+
git -C "$MEMORY_DIR" commit -q -m "Ripcord: auto-handoff for $author ($slug)" 2>/dev/null
|
|
321
|
+
if git -C "$MEMORY_DIR" remote get-url origin >/dev/null 2>&1; then
|
|
322
|
+
local i
|
|
323
|
+
for i in 1 2 3; do
|
|
324
|
+
git -C "$MEMORY_DIR" pull --rebase origin main --quiet 2>/dev/null && \
|
|
325
|
+
git -C "$MEMORY_DIR" push origin main --quiet 2>/dev/null && break
|
|
326
|
+
sleep 2
|
|
327
|
+
done
|
|
328
|
+
fi
|
|
329
|
+
bash "$MAIN_DIR/bin/index-handoff.sh" "$rel" >/dev/null 2>&1 || true
|
|
330
|
+
|
|
331
|
+
echo "ripcord $base $rel" >> "$JOURNAL"
|
|
332
|
+
log "ripcord → memory/$rel"
|
|
333
|
+
_trigger_auto_thread "ripcord"
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
# --- Auto-push -------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
_auto_push() {
|
|
339
|
+
local wt branch dirty ahead upstream
|
|
340
|
+
git -C "$MAIN_DIR" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | while read -r wt; do
|
|
341
|
+
branch=$(git -C "$wt" branch --show-current 2>/dev/null)
|
|
342
|
+
case "$branch" in dev/*|feature/*|bugfix/*) ;; *) continue ;; esac
|
|
343
|
+
# Mid-rebase/merge — never touch.
|
|
344
|
+
local gd; gd=$(git -C "$wt" rev-parse --git-dir 2>/dev/null)
|
|
345
|
+
{ [ -d "$gd/rebase-merge" ] || [ -d "$gd/rebase-apply" ] || [ -f "$gd/MERGE_HEAD" ]; } && continue
|
|
346
|
+
|
|
347
|
+
dirty=$(git -C "$wt" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
348
|
+
# Only act on work that has itself gone quiet (nothing modified recently).
|
|
349
|
+
if [ "$dirty" != "0" ]; then
|
|
350
|
+
local fresh
|
|
351
|
+
fresh=$(git -C "$wt" status --porcelain 2>/dev/null | sed 's/^...//' | head -50 | while read -r f; do
|
|
352
|
+
find "$wt/$f" -mmin "-$STALE_MIN" 2>/dev/null | head -1
|
|
353
|
+
done | head -1)
|
|
354
|
+
[ -n "$fresh" ] && continue
|
|
355
|
+
if [ "$DRY_RUN" = "1" ]; then log "DRY auto-commit $branch ($dirty files, $wt)"; else
|
|
356
|
+
git -C "$wt" add -A 2>/dev/null
|
|
357
|
+
git -C "$wt" commit -q -m "chore(attendant): ripcord auto-save
|
|
358
|
+
|
|
359
|
+
Uncommitted work captured after the session went quiet.
|
|
360
|
+
|
|
361
|
+
Co-Authored-By: Egregore Attendant <noreply@egregore.xyz>" 2>/dev/null
|
|
362
|
+
log "auto-commit $branch ($dirty files)"
|
|
363
|
+
fi
|
|
364
|
+
fi
|
|
365
|
+
|
|
366
|
+
upstream=$(git -C "$wt" rev-parse --abbrev-ref '@{u}' 2>/dev/null || true)
|
|
367
|
+
if [ -z "$upstream" ]; then
|
|
368
|
+
ahead=$(git -C "$wt" rev-list --count origin/develop..HEAD 2>/dev/null || echo 0)
|
|
369
|
+
else
|
|
370
|
+
ahead=$(git -C "$wt" rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)
|
|
371
|
+
fi
|
|
372
|
+
if [ "${ahead:-0}" != "0" ]; then
|
|
373
|
+
if [ "$DRY_RUN" = "1" ]; then log "DRY auto-push $branch ($ahead commits)"; else
|
|
374
|
+
git -C "$wt" push -u origin "$branch" --quiet 2>/dev/null && log "auto-push $branch ($ahead commits)"
|
|
375
|
+
fi
|
|
376
|
+
fi
|
|
377
|
+
done
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
# --- Warm: pre-pay the launch path -------------------------------------------
|
|
381
|
+
# Session-start's cost is network: git fetches + the greeting's graph queries.
|
|
382
|
+
# The attendant pays them in the background every WARM_MIN minutes so launch
|
|
383
|
+
# finds fresh refs (git-sync skips its fetch+wait) and a hot graph cache
|
|
384
|
+
# (context.sh reads answers instead of making round-trips). Runs even while
|
|
385
|
+
# sessions are active — fetch and read-only replay are safe mid-work.
|
|
386
|
+
|
|
387
|
+
WARM_MIN="${ATTENDANT_WARM_MIN:-5}"
|
|
388
|
+
WARM_MARKER="$STATE_DIR/$KEY.warm-ts"
|
|
389
|
+
|
|
390
|
+
_warm() {
|
|
391
|
+
local now last
|
|
392
|
+
now=$(date +%s)
|
|
393
|
+
last=$(cat "$WARM_MARKER" 2>/dev/null || echo 0)
|
|
394
|
+
[ $(( now - last )) -lt $(( WARM_MIN * 60 )) ] && return 0
|
|
395
|
+
[ "$DRY_RUN" = "1" ] && { log "DRY warm"; return 0; }
|
|
396
|
+
|
|
397
|
+
git -C "$MAIN_DIR" fetch origin --quiet 2>/dev/null &
|
|
398
|
+
[ -d "$MEMORY_DIR/.git" ] || [ -f "$MEMORY_DIR/.git" ] && git -C "$MEMORY_DIR" fetch origin --quiet 2>/dev/null &
|
|
399
|
+
local repo
|
|
400
|
+
jq -r '(.repos[]? // empty) | if type == "object" then .name else . end' "$CONFIG" 2>/dev/null | while read -r repo; do
|
|
401
|
+
[ -d "$MAIN_DIR/../$repo/.git" ] && git -C "$MAIN_DIR/../$repo" fetch origin --quiet 2>/dev/null
|
|
402
|
+
done &
|
|
403
|
+
wait
|
|
404
|
+
date +%s > "$WARM_MARKER"
|
|
405
|
+
|
|
406
|
+
# Replay graph cache entries touched in the last 24h (connected mode only —
|
|
407
|
+
# in local mode graph.sh exits instantly and no cache exists).
|
|
408
|
+
local cache_dir f q p
|
|
409
|
+
cache_dir="$HOME/.egregore/graph-cache/$(echo -n "$MAIN_DIR" | cksum | cut -d' ' -f1)"
|
|
410
|
+
if [ -d "$cache_dir" ]; then
|
|
411
|
+
for f in $(find "$cache_dir" -name '*.json' -mmin -1440 2>/dev/null); do
|
|
412
|
+
q=$(jq -r '.query // empty' "$f" 2>/dev/null)
|
|
413
|
+
p=$(jq -c '.params // {}' "$f" 2>/dev/null)
|
|
414
|
+
[ -n "$q" ] && EGREGORE_GRAPH_CACHE_TTL=1 bash "$MAIN_DIR/bin/graph.sh" query "$q" "$p" >/dev/null 2>&1 || true
|
|
415
|
+
done
|
|
416
|
+
fi
|
|
417
|
+
log "warm: refs fetched, graph cache replayed"
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
# --- Handoff lifecycle: recurring close / nudge / expiry --------------------
|
|
421
|
+
# Connected-mode only. The job itself filters to lifecycle-v1 handoffs with an
|
|
422
|
+
# explicit intent, so legacy unclassified backfill never runs unattended.
|
|
423
|
+
|
|
424
|
+
LIFECYCLE_MIN="${ATTENDANT_LIFECYCLE_MIN:-360}"
|
|
425
|
+
LIFECYCLE_MARKER="$STATE_DIR/$KEY.handoff-lifecycle-ts"
|
|
426
|
+
|
|
427
|
+
_handoff_lifecycle() {
|
|
428
|
+
local now last mode api_url api_key result
|
|
429
|
+
[ "$(jq -r '.features.handoff_lifecycle // true' "$CONFIG" 2>/dev/null)" = "true" ] || return 0
|
|
430
|
+
[ -f "$MAIN_DIR/bin/handoff-lifecycle-job.sh" ] || return 0
|
|
431
|
+
|
|
432
|
+
mode=$(jq -r '.mode // "connected"' "$CONFIG" 2>/dev/null)
|
|
433
|
+
api_url=$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)
|
|
434
|
+
api_key=$(grep '^EGREGORE_API_KEY=' "$MAIN_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)
|
|
435
|
+
[ "$mode" = "connected" ] && [ -n "$api_url" ] && [ -n "$api_key" ] || return 0
|
|
436
|
+
|
|
437
|
+
now=$(date +%s)
|
|
438
|
+
last=$(cat "$LIFECYCLE_MARKER" 2>/dev/null || echo 0)
|
|
439
|
+
[ $(( now - last )) -lt $(( LIFECYCLE_MIN * 60 )) ] && return 0
|
|
440
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
441
|
+
log "DRY handoff-lifecycle"
|
|
442
|
+
return 0
|
|
443
|
+
fi
|
|
444
|
+
|
|
445
|
+
if result=$(bash "$MAIN_DIR/bin/handoff-lifecycle-job.sh" run 2>&1); then
|
|
446
|
+
log "handoff-lifecycle: $(printf '%s' "$result" | jq -c \
|
|
447
|
+
'{transitions,nudges:(.nudges|length)}' 2>/dev/null || echo success)"
|
|
448
|
+
else
|
|
449
|
+
log "handoff-lifecycle failed: $(printf '%s' "$result" | tail -1 | cut -c1-300)"
|
|
450
|
+
fi
|
|
451
|
+
date +%s > "$LIFECYCLE_MARKER"
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
# --- Commands ---------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
cmd_sweep() {
|
|
457
|
+
_enabled || return 0
|
|
458
|
+
_warm
|
|
459
|
+
_handoff_lifecycle
|
|
460
|
+
if _active_session; then
|
|
461
|
+
log "sweep: session active — standing by"
|
|
462
|
+
return 0
|
|
463
|
+
fi
|
|
464
|
+
local t
|
|
465
|
+
for t in $(_stale_transcripts); do
|
|
466
|
+
_ripcord "$t"
|
|
467
|
+
done
|
|
468
|
+
_auto_push
|
|
469
|
+
_trigger_auto_thread "sweep"
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
cmd_run() {
|
|
473
|
+
_enabled || exit 0
|
|
474
|
+
echo $$ > "$PIDFILE"
|
|
475
|
+
_script_version > "$VERSIONFILE"
|
|
476
|
+
log "attendant up (pid $$, stale=${STALE_MIN}m, $MAIN_DIR)"
|
|
477
|
+
while true; do
|
|
478
|
+
[ -d "$MAIN_DIR" ] || { log "project gone — exiting"; break; }
|
|
479
|
+
cmd_sweep
|
|
480
|
+
sleep 60
|
|
481
|
+
done
|
|
482
|
+
rm -f "$PIDFILE"
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
cmd_ensure() {
|
|
486
|
+
_enabled || exit 0
|
|
487
|
+
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
|
|
488
|
+
if [ "$(cat "$VERSIONFILE" 2>/dev/null)" = "$(_script_version)" ]; then
|
|
489
|
+
exit 0
|
|
490
|
+
fi
|
|
491
|
+
log "attendant code changed — restarting"
|
|
492
|
+
kill "$(cat "$PIDFILE")" 2>/dev/null || true
|
|
493
|
+
rm -f "$PIDFILE"
|
|
494
|
+
fi
|
|
495
|
+
nohup bash "$MAIN_DIR/bin/attendant.sh" run >> "$LOGFILE" 2>&1 &
|
|
496
|
+
disown 2>/dev/null || true
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
cmd_status() {
|
|
500
|
+
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
|
|
501
|
+
echo "attendant: running (pid $(cat "$PIDFILE"), key $KEY)"
|
|
502
|
+
else
|
|
503
|
+
echo "attendant: not running (key $KEY)"
|
|
504
|
+
fi
|
|
505
|
+
[ -f "$LOGFILE" ] && tail -5 "$LOGFILE"
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
cmd_stop() {
|
|
509
|
+
if [ -f "$PIDFILE" ]; then
|
|
510
|
+
kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null
|
|
511
|
+
rm -f "$PIDFILE" "$VERSIONFILE"
|
|
512
|
+
echo "attendant: stopped"
|
|
513
|
+
else
|
|
514
|
+
echo "attendant: not running"
|
|
515
|
+
fi
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
case "${1:-}" in
|
|
519
|
+
ensure) cmd_ensure ;;
|
|
520
|
+
run) cmd_run ;;
|
|
521
|
+
sweep) cmd_sweep ;;
|
|
522
|
+
status) cmd_status ;;
|
|
523
|
+
stop) cmd_stop ;;
|
|
524
|
+
*) echo "usage: bash bin/attendant.sh {ensure|run|sweep|status|stop}" >&2; exit 1 ;;
|
|
525
|
+
esac
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Detached reconciliation worker for explicit captures.
|
|
5
|
+
# The foreground path only appends to graph-wal.sh; this worker pays network
|
|
6
|
+
# latency, retries queued writes, and sends completion notifications.
|
|
7
|
+
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
9
|
+
MODE="$(jq -r '.mode // "connected"' "$SCRIPT_DIR/egregore.json" 2>/dev/null || echo connected)"
|
|
10
|
+
[ "$MODE" = "connected" ] || exit 0
|
|
11
|
+
|
|
12
|
+
SESSION_ID=""
|
|
13
|
+
AUTHOR=""
|
|
14
|
+
SUMMARY=""
|
|
15
|
+
while [ $# -gt 0 ]; do
|
|
16
|
+
case "$1" in
|
|
17
|
+
--session-id) SESSION_ID="${2:-}"; shift 2 ;;
|
|
18
|
+
--author) AUTHOR="${2:-}"; shift 2 ;;
|
|
19
|
+
--summary) SUMMARY="${2:-}"; shift 2 ;;
|
|
20
|
+
*) echo "Unknown reconcile option: $1" >&2; exit 1 ;;
|
|
21
|
+
esac
|
|
22
|
+
done
|
|
23
|
+
[ -n "$SESSION_ID" ] || exit 0
|
|
24
|
+
|
|
25
|
+
# Applies Session capture first, then the idempotent handoff transition.
|
|
26
|
+
bash "$SCRIPT_DIR/bin/graph-wal.sh" drain >/dev/null 2>&1 || exit 0
|
|
27
|
+
|
|
28
|
+
# Claim completion notifications atomically. A stale claim can be retried;
|
|
29
|
+
# successful sends are permanently marked on the IMPLEMENTS relationship.
|
|
30
|
+
# shellcheck disable=SC2016 # $name tokens are Cypher parameters.
|
|
31
|
+
CLAIM_CYPHER='
|
|
32
|
+
MATCH (impl:Session {id: $sessionId})-[r:IMPLEMENTS]->(ho:Session)-[:BY]->(origin:Person)
|
|
33
|
+
WHERE ho.handoffStatus = "done"
|
|
34
|
+
AND r.completionNotifiedAt IS NULL
|
|
35
|
+
AND (r.completionNotifyClaimedAt IS NULL
|
|
36
|
+
OR r.completionNotifyClaimedAt < datetime() - duration("PT1H"))
|
|
37
|
+
SET r.completionNotifyClaimedAt = datetime()
|
|
38
|
+
RETURN ho.id AS handoffId, ho.topic AS topic,
|
|
39
|
+
coalesce(origin.github, origin.name) AS recipient'
|
|
40
|
+
PARAMS="$(jq -nc --arg sessionId "$SESSION_ID" '{sessionId:$sessionId}')"
|
|
41
|
+
CLAIMS="$(bash "$SCRIPT_DIR/bin/graph.sh" query "$CLAIM_CYPHER" "$PARAMS" 2>/dev/null || echo '{}')"
|
|
42
|
+
|
|
43
|
+
printf '%s' "$CLAIMS" | jq -r '.values[]? | @tsv' 2>/dev/null |
|
|
44
|
+
while IFS=$'\t' read -r handoff_id topic recipient; do
|
|
45
|
+
[ -n "$handoff_id" ] && [ -n "$recipient" ] || continue
|
|
46
|
+
message="${AUTHOR:-A teammate} completed your handoff '${topic:-$handoff_id}'"
|
|
47
|
+
[ -n "$SUMMARY" ] && message="${message} — $(printf '%s' "$SUMMARY" | tr '\n' ' ' | cut -c1-180)"
|
|
48
|
+
|
|
49
|
+
notify_result="$(bash "$SCRIPT_DIR/bin/notify.sh" send "$recipient" "$message" 2>/dev/null || echo Failed)"
|
|
50
|
+
if printf '%s' "$notify_result" | grep -qi '^Sent'; then
|
|
51
|
+
bash "$SCRIPT_DIR/bin/graph.sh" query '
|
|
52
|
+
MATCH (impl:Session {id: $sessionId})-[r:IMPLEMENTS]->(ho:Session {id: $handoffId})
|
|
53
|
+
SET r.completionNotifiedAt = datetime(),
|
|
54
|
+
r.completionNotifyClaimedAt = null
|
|
55
|
+
RETURN ho.id AS id
|
|
56
|
+
' "$(jq -nc --arg sessionId "$SESSION_ID" --arg handoffId "$handoff_id" \
|
|
57
|
+
'{sessionId:$sessionId,handoffId:$handoffId}')" >/dev/null 2>&1 || true
|
|
58
|
+
else
|
|
59
|
+
bash "$SCRIPT_DIR/bin/graph.sh" query '
|
|
60
|
+
MATCH (impl:Session {id: $sessionId})-[r:IMPLEMENTS]->(ho:Session {id: $handoffId})
|
|
61
|
+
SET r.completionNotifyClaimedAt = null,
|
|
62
|
+
r.completionNotifyFailedAt = datetime()
|
|
63
|
+
RETURN ho.id AS id
|
|
64
|
+
' "$(jq -nc --arg sessionId "$SESSION_ID" --arg handoffId "$handoff_id" \
|
|
65
|
+
'{sessionId:$sessionId,handoffId:$handoffId}')" >/dev/null 2>&1 || true
|
|
66
|
+
fi
|
|
67
|
+
done
|