create-egregore 0.19.8 → 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.
Files changed (91) hide show
  1. package/lib/local.js +11 -1
  2. package/lib/setup.js +33 -0
  3. package/package.json +1 -1
  4. package/runtime/codex/.codex/hooks/branch-guard.js +242 -34
  5. package/runtime/codex/.codex/skills/activity/SKILL.md +18 -6
  6. package/runtime/codex/.codex/skills/deep-reflect/SKILL.md +90 -55
  7. package/runtime/codex/.codex/skills/deploy-bridge/SKILL.md +25 -0
  8. package/runtime/codex/.codex/skills/handoff/SKILL.md +6 -2
  9. package/runtime/codex/.codex/skills/save/SKILL.md +21 -3
  10. package/runtime/codex/.codex/skills/scroll/SKILL.md +54 -3
  11. package/runtime/codex/.codex/skills/task/SKILL.md +25 -0
  12. package/runtime/codex/.codex/skills/tui-design/SKILL.md +1 -1
  13. package/runtime/codex/.codex/skills/wrap/SKILL.md +4 -1
  14. package/runtime/codex/.codex/spec-manifest.json +15 -3
  15. package/runtime/codex/AGENTS.md +45 -9
  16. package/runtime/codex/bin/activity-data.sh +1 -1
  17. package/runtime/codex/bin/agent.sh +66 -23
  18. package/runtime/codex/bin/attendant.sh +525 -0
  19. package/runtime/codex/bin/capture-reconcile.sh +67 -0
  20. package/runtime/codex/bin/capture-run.sh +341 -0
  21. package/runtime/codex/bin/codex-render-spec.mjs +15 -4
  22. package/runtime/codex/bin/codex-session-start.sh +45 -3
  23. package/runtime/codex/bin/codex-skill-render.mjs +23 -4
  24. package/runtime/codex/bin/graph-op.sh +719 -49
  25. package/runtime/codex/bin/graph-wal.sh +3 -2
  26. package/runtime/codex/bin/handoff-run.sh +72 -0
  27. package/runtime/codex/bin/handoff-save-egregore.sh +51 -11
  28. package/runtime/codex/bin/index-handoff.sh +26 -17
  29. package/runtime/codex/bin/ingest-graph.sh +80 -0
  30. package/runtime/codex/bin/ingest-surface.html +429 -0
  31. package/runtime/codex/bin/ingest.py +614 -0
  32. package/runtime/codex/bin/ingest.sh +231 -0
  33. package/runtime/codex/bin/ingest_graph.py +776 -0
  34. package/runtime/codex/bin/ingest_surface.py +464 -0
  35. package/runtime/codex/bin/lib/git-sync.sh +2 -6
  36. package/runtime/codex/bin/lib/greeting.sh +157 -22
  37. package/runtime/codex/bin/lib/identity.sh +50 -29
  38. package/runtime/codex/bin/lib/noncode.sh +31 -0
  39. package/runtime/codex/bin/person.py +610 -0
  40. package/runtime/codex/bin/person.sh +448 -0
  41. package/runtime/codex/bin/session-autosave.sh +184 -0
  42. package/runtime/codex/bin/session-log.sh +199 -0
  43. package/runtime/codex/bin/settings.sh +1 -1
  44. package/runtime/codex/manifest.json +19 -1
  45. package/runtime/pi/.codex/hooks/branch-guard.js +242 -34
  46. package/runtime/pi/.codex/skills/activity/SKILL.md +18 -6
  47. package/runtime/pi/.codex/skills/deep-reflect/SKILL.md +90 -55
  48. package/runtime/pi/.codex/skills/deploy-bridge/SKILL.md +25 -0
  49. package/runtime/pi/.codex/skills/handoff/SKILL.md +6 -2
  50. package/runtime/pi/.codex/skills/save/SKILL.md +21 -3
  51. package/runtime/pi/.codex/skills/scroll/SKILL.md +54 -3
  52. package/runtime/pi/.codex/skills/task/SKILL.md +25 -0
  53. package/runtime/pi/.codex/skills/tui-design/SKILL.md +1 -1
  54. package/runtime/pi/.codex/skills/wrap/SKILL.md +4 -1
  55. package/runtime/pi/.codex/spec-manifest.json +15 -3
  56. package/runtime/pi/.pi/APPEND_SYSTEM.md +41 -5
  57. package/runtime/pi/.pi/extensions/egregore.ts +98 -8
  58. package/runtime/pi/.pi/spec-manifest.json +2 -2
  59. package/runtime/pi/AGENTS.md +45 -9
  60. package/runtime/pi/bin/activity-data.sh +1 -1
  61. package/runtime/pi/bin/agent.sh +66 -23
  62. package/runtime/pi/bin/attendant.sh +525 -0
  63. package/runtime/pi/bin/capture-reconcile.sh +67 -0
  64. package/runtime/pi/bin/capture-run.sh +341 -0
  65. package/runtime/pi/bin/codex-render-spec.mjs +15 -4
  66. package/runtime/pi/bin/codex-session-start.sh +45 -3
  67. package/runtime/pi/bin/codex-skill-render.mjs +23 -4
  68. package/runtime/pi/bin/graph-op.sh +719 -49
  69. package/runtime/pi/bin/graph-wal.sh +3 -2
  70. package/runtime/pi/bin/handoff-run.sh +72 -0
  71. package/runtime/pi/bin/handoff-save-egregore.sh +51 -11
  72. package/runtime/pi/bin/index-handoff.sh +26 -17
  73. package/runtime/pi/bin/ingest-graph.sh +80 -0
  74. package/runtime/pi/bin/ingest-surface.html +429 -0
  75. package/runtime/pi/bin/ingest.py +614 -0
  76. package/runtime/pi/bin/ingest.sh +231 -0
  77. package/runtime/pi/bin/ingest_graph.py +776 -0
  78. package/runtime/pi/bin/ingest_surface.py +464 -0
  79. package/runtime/pi/bin/lib/git-sync.sh +2 -6
  80. package/runtime/pi/bin/lib/greeting.sh +157 -22
  81. package/runtime/pi/bin/lib/identity.sh +50 -29
  82. package/runtime/pi/bin/lib/noncode.sh +31 -0
  83. package/runtime/pi/bin/person.py +610 -0
  84. package/runtime/pi/bin/person.sh +448 -0
  85. package/runtime/pi/bin/pi-branch-policy.mjs +164 -4
  86. package/runtime/pi/bin/pi-product-workflows.mjs +31 -2
  87. package/runtime/pi/bin/publish-artifact.sh +14 -0
  88. package/runtime/pi/bin/session-autosave.sh +184 -0
  89. package/runtime/pi/bin/session-log.sh +199 -0
  90. package/runtime/pi/bin/settings.sh +274 -0
  91. 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