create-egregore 0.19.32 → 0.20.0

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 (173) hide show
  1. package/assets/egregore-launcher.js +118 -13
  2. package/bin/cli.js +48 -3
  3. package/lib/connect-runtime.js +4 -2
  4. package/lib/local.js +15 -1
  5. package/lib/prime-runtime.js +208 -0
  6. package/lib/setup.js +45 -3
  7. package/package.json +5 -4
  8. package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +29 -0
  9. package/runtime/codex/.codex/skills/notion-connect/SKILL.md +29 -0
  10. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
  11. package/runtime/codex/.codex/spec-manifest.json +3 -3
  12. package/runtime/codex/AGENTS.md +11 -4
  13. package/runtime/codex/bin/activity-data.sh +4 -8
  14. package/runtime/codex/bin/attendant.sh +13 -0
  15. package/runtime/codex/bin/autosave.sh +27 -1
  16. package/runtime/codex/bin/codex-render-spec.mjs +6 -0
  17. package/runtime/codex/bin/codex-session-start.sh +8 -1
  18. package/runtime/codex/bin/connect-refresh.sh +33 -0
  19. package/runtime/codex/bin/graph-batch.sh +25 -6
  20. package/runtime/codex/bin/graph-op.sh +5 -4
  21. package/runtime/codex/bin/graph.sh +31 -5
  22. package/runtime/codex/bin/ingest.py +81 -5
  23. package/runtime/codex/bin/lib/context.sh +26 -1
  24. package/runtime/codex/bin/lib/greeting.sh +4 -1
  25. package/runtime/codex/bin/lib/worktree-links.sh +25 -0
  26. package/runtime/codex/manifest.json +8 -1
  27. package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +29 -0
  28. package/runtime/pi/.codex/skills/notion-connect/SKILL.md +29 -0
  29. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
  30. package/runtime/pi/.codex/spec-manifest.json +3 -3
  31. package/runtime/pi/.pi/APPEND_SYSTEM.md +3 -2
  32. package/runtime/pi/.pi/extensions/egregore.ts +17 -6
  33. package/runtime/pi/.pi/spec-manifest.json +2 -2
  34. package/runtime/pi/AGENTS.md +11 -4
  35. package/runtime/pi/bin/activity-data.sh +4 -8
  36. package/runtime/pi/bin/attendant.sh +13 -0
  37. package/runtime/pi/bin/autosave.sh +27 -1
  38. package/runtime/pi/bin/codex-render-spec.mjs +6 -0
  39. package/runtime/pi/bin/codex-session-start.sh +8 -1
  40. package/runtime/pi/bin/connect-refresh.sh +33 -0
  41. package/runtime/pi/bin/graph-batch.sh +25 -6
  42. package/runtime/pi/bin/graph-op.sh +5 -4
  43. package/runtime/pi/bin/graph.sh +31 -5
  44. package/runtime/pi/bin/ingest.py +81 -5
  45. package/runtime/pi/bin/lib/context.sh +26 -1
  46. package/runtime/pi/bin/lib/greeting.sh +4 -1
  47. package/runtime/pi/bin/lib/worktree-links.sh +25 -0
  48. package/runtime/pi/bin/pi-branch-policy.mjs +138 -1
  49. package/runtime/pi/bin/pi-product-workflows.mjs +35 -7
  50. package/runtime/pi/manifest.json +8 -1
  51. package/runtime/prime/.claude/context/notification-consent.md +66 -0
  52. package/runtime/prime/.codex/config.toml +17 -0
  53. package/runtime/prime/.codex/hooks/branch-guard.js +461 -0
  54. package/runtime/prime/.codex/hooks/search-hint.js +67 -0
  55. package/runtime/prime/.codex/hooks.json +30 -0
  56. package/runtime/prime/.codex/skills/activity/SKILL.md +82 -0
  57. package/runtime/prime/.codex/skills/announce/SKILL.md +67 -0
  58. package/runtime/prime/.codex/skills/archive/SKILL.md +45 -0
  59. package/runtime/prime/.codex/skills/branch/SKILL.md +29 -0
  60. package/runtime/prime/.codex/skills/checkup/SKILL.md +45 -0
  61. package/runtime/prime/.codex/skills/commit/SKILL.md +29 -0
  62. package/runtime/prime/.codex/skills/contribute/SKILL.md +29 -0
  63. package/runtime/prime/.codex/skills/create-skill/SKILL.md +29 -0
  64. package/runtime/prime/.codex/skills/dark-mode/SKILL.md +29 -0
  65. package/runtime/prime/.codex/skills/dashboard/SKILL.md +59 -0
  66. package/runtime/prime/.codex/skills/deep-reflect/SKILL.md +109 -0
  67. package/runtime/prime/.codex/skills/delete-user/SKILL.md +29 -0
  68. package/runtime/prime/.codex/skills/emissary/SKILL.md +45 -0
  69. package/runtime/prime/.codex/skills/handoff/SKILL.md +166 -0
  70. package/runtime/prime/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
  71. package/runtime/prime/.codex/skills/harvest/SKILL.md +131 -0
  72. package/runtime/prime/.codex/skills/ingest/SKILL.md +29 -0
  73. package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +29 -0
  74. package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +45 -0
  75. package/runtime/prime/.codex/skills/invite/SKILL.md +44 -0
  76. package/runtime/prime/.codex/skills/issue/SKILL.md +45 -0
  77. package/runtime/prime/.codex/skills/me/SKILL.md +29 -0
  78. package/runtime/prime/.codex/skills/meeting/SKILL.md +45 -0
  79. package/runtime/prime/.codex/skills/note/SKILL.md +29 -0
  80. package/runtime/prime/.codex/skills/notion-connect/SKILL.md +29 -0
  81. package/runtime/prime/.codex/skills/onboarding/SKILL.md +45 -0
  82. package/runtime/prime/.codex/skills/pr/SKILL.md +29 -0
  83. package/runtime/prime/.codex/skills/pull/SKILL.md +29 -0
  84. package/runtime/prime/.codex/skills/push/SKILL.md +29 -0
  85. package/runtime/prime/.codex/skills/quest/SKILL.md +80 -0
  86. package/runtime/prime/.codex/skills/reflect/SKILL.md +45 -0
  87. package/runtime/prime/.codex/skills/review-pr/SKILL.md +45 -0
  88. package/runtime/prime/.codex/skills/save/SKILL.md +125 -0
  89. package/runtime/prime/.codex/skills/slack-connect/SKILL.md +29 -0
  90. package/runtime/prime/.codex/skills/summon/SKILL.md +45 -0
  91. package/runtime/prime/.codex/skills/sync-repos/SKILL.md +29 -0
  92. package/runtime/prime/.codex/skills/teams-connect/SKILL.md +29 -0
  93. package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +29 -0
  94. package/runtime/prime/.codex/skills/telemetry/SKILL.md +29 -0
  95. package/runtime/prime/.codex/skills/test/SKILL.md +45 -0
  96. package/runtime/prime/.codex/skills/the-spiral/SKILL.md +84 -0
  97. package/runtime/prime/.codex/skills/tui-design/SKILL.md +45 -0
  98. package/runtime/prime/.codex/skills/tutorial/SKILL.md +45 -0
  99. package/runtime/prime/.codex/skills/update/SKILL.md +29 -0
  100. package/runtime/prime/.codex/skills/view/SKILL.md +156 -0
  101. package/runtime/prime/.codex/skills/view/agents/openai.yaml +4 -0
  102. package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +221 -0
  103. package/runtime/prime/.codex/skills/visual-explain/SKILL.md +45 -0
  104. package/runtime/prime/.codex/skills/wrap/SKILL.md +58 -0
  105. package/runtime/prime/.codex/spec-manifest.json +132 -0
  106. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +393 -0
  107. package/runtime/prime/.prime/agent/extensions/egregore.ts +460 -0
  108. package/runtime/prime/.prime/agent/settings.json +4 -0
  109. package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +34 -0
  110. package/runtime/prime/.prime/agent/skills/egregore/pyproject.toml +13 -0
  111. package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +156 -0
  112. package/runtime/prime/.prime/agent/spec-manifest.json +12 -0
  113. package/runtime/prime/AGENTS.md +489 -0
  114. package/runtime/prime/bin/activity-data.sh +238 -0
  115. package/runtime/prime/bin/agent.sh +775 -0
  116. package/runtime/prime/bin/attendant.sh +707 -0
  117. package/runtime/prime/bin/autosave.sh +112 -0
  118. package/runtime/prime/bin/capture-reconcile.sh +69 -0
  119. package/runtime/prime/bin/capture-run.sh +341 -0
  120. package/runtime/prime/bin/codex-install-skills.sh +78 -0
  121. package/runtime/prime/bin/codex-render-spec.mjs +440 -0
  122. package/runtime/prime/bin/codex-session-start.sh +265 -0
  123. package/runtime/prime/bin/codex-skill-render.mjs +347 -0
  124. package/runtime/prime/bin/codex-sync-skills.sh +232 -0
  125. package/runtime/prime/bin/connect-refresh.sh +33 -0
  126. package/runtime/prime/bin/dashboard-data.sh +178 -0
  127. package/runtime/prime/bin/graph-batch.sh +79 -0
  128. package/runtime/prime/bin/graph-op.sh +1102 -0
  129. package/runtime/prime/bin/graph-wal.sh +229 -0
  130. package/runtime/prime/bin/graph.sh +223 -0
  131. package/runtime/prime/bin/handoff-pr-backfill.sh +86 -0
  132. package/runtime/prime/bin/handoff-run.sh +671 -0
  133. package/runtime/prime/bin/handoff-save-egregore.sh +212 -0
  134. package/runtime/prime/bin/index-handoff.sh +322 -0
  135. package/runtime/prime/bin/ingest-graph.sh +80 -0
  136. package/runtime/prime/bin/ingest-surface.html +495 -0
  137. package/runtime/prime/bin/ingest.py +756 -0
  138. package/runtime/prime/bin/ingest.sh +279 -0
  139. package/runtime/prime/bin/ingest_graph.py +856 -0
  140. package/runtime/prime/bin/ingest_surface.py +544 -0
  141. package/runtime/prime/bin/lib/artifact-id.sh +46 -0
  142. package/runtime/prime/bin/lib/config.sh +116 -0
  143. package/runtime/prime/bin/lib/context.sh +621 -0
  144. package/runtime/prime/bin/lib/dashboard-artifact.sh +242 -0
  145. package/runtime/prime/bin/lib/ensure-develop.sh +183 -0
  146. package/runtime/prime/bin/lib/git-sync.sh +406 -0
  147. package/runtime/prime/bin/lib/greeting.sh +519 -0
  148. package/runtime/prime/bin/lib/hash.sh +13 -0
  149. package/runtime/prime/bin/lib/identity.sh +213 -0
  150. package/runtime/prime/bin/lib/metrics.sh +374 -0
  151. package/runtime/prime/bin/lib/noncode.sh +31 -0
  152. package/runtime/prime/bin/lib/permissions.sh +106 -0
  153. package/runtime/prime/bin/lib/time.sh +56 -0
  154. package/runtime/prime/bin/lib/worktree-links.sh +79 -0
  155. package/runtime/prime/bin/notify.sh +636 -0
  156. package/runtime/prime/bin/person.py +630 -0
  157. package/runtime/prime/bin/person.sh +448 -0
  158. package/runtime/prime/bin/pi-branch-policy.mjs +312 -0
  159. package/runtime/prime/bin/pi-product-workflows.mjs +394 -0
  160. package/runtime/prime/bin/prime-render-spec.mjs +123 -0
  161. package/runtime/prime/bin/prime-session-start.sh +24 -0
  162. package/runtime/prime/bin/publish-artifact.sh +271 -0
  163. package/runtime/prime/bin/publish-references.sh +144 -0
  164. package/runtime/prime/bin/repo-state.sh +96 -0
  165. package/runtime/prime/bin/search.sh +240 -0
  166. package/runtime/prime/bin/session-autosave.sh +209 -0
  167. package/runtime/prime/bin/session-log.sh +199 -0
  168. package/runtime/prime/bin/settings.sh +304 -0
  169. package/runtime/prime/bin/telemetry.sh +391 -0
  170. package/runtime/prime/bin/worktree-create.sh +128 -0
  171. package/runtime/prime/bin/worktree-remove.sh +50 -0
  172. package/runtime/prime/bin/worktree.sh +198 -0
  173. package/runtime/prime/manifest.json +178 -0
@@ -0,0 +1,707 @@
1
+ #!/usr/bin/env 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
+ # Personal off switch for ambient capture. ripcord_enabled=false disables BOTH
103
+ # ripcord notes and auto-push of dirty worktrees — a user who opts out of
104
+ # ambient capture must not find silent commits either. The attendant's other
105
+ # duties (graph cache, context seed, handoff lifecycle) are unaffected.
106
+ # NOTE: jq's `//` treats false as empty (the auto_update:false gotcha), so the
107
+ # test compares the raw value, never `.key // true`.
108
+ _capture_enabled() {
109
+ [ "$(jq -r '.ripcord_enabled' "$MAIN_DIR/.egregore-state.json" 2>/dev/null)" != "false" ]
110
+ }
111
+
112
+ _author() {
113
+ local a
114
+ a=$(jq -r '.github_username // empty' "$MAIN_DIR/.egregore-state.json" 2>/dev/null)
115
+ [ -z "$a" ] && a=$(git -C "$MAIN_DIR" config user.name 2>/dev/null | tr ' ' '-' | tr '[:upper:]' '[:lower:]')
116
+ echo "${a:-unknown}"
117
+ }
118
+
119
+ # --- Session liveness ------------------------------------------------------
120
+
121
+ _transcript_dirs() {
122
+ find "$CLAUDE_PROJECTS" -maxdepth 1 -type d -name "${PROJ_KEY}*" 2>/dev/null
123
+ }
124
+
125
+ _active_session() {
126
+ local d
127
+ for d in $(_transcript_dirs); do
128
+ if [ -n "$(find "$d" -maxdepth 1 -name '*.jsonl' -mmin "-$STALE_MIN" 2>/dev/null | head -1)" ]; then
129
+ return 0
130
+ fi
131
+ done
132
+ return 1
133
+ }
134
+
135
+ _stale_transcripts() {
136
+ local d
137
+ for d in $(_transcript_dirs); do
138
+ find "$d" -maxdepth 1 -name '*.jsonl' -mmin "+$STALE_MIN" -mmin "-$MAX_AGE_MIN" -size "+${RIPCORD_MIN_BYTES}c" 2>/dev/null
139
+ done
140
+ }
141
+
142
+ # --- Ripcord ---------------------------------------------------------------
143
+
144
+ _last_prompts() {
145
+ # Last few human prompts from a transcript, cleaned of harness noise.
146
+ jq -r 'select(.type=="user") | .message.content
147
+ | if type=="string" then .
148
+ else ([.[]? | select(.type?=="text") | .text] | join(" ")) end' "$1" 2>/dev/null \
149
+ | grep -v -e '^\s*$' -e 'system-reminder' -e '<command-' -e 'tool_use_error' -e '^Caveat:' -e '<task-notification' \
150
+ -e '<local-command-caveat' -e '</local-command-caveat>' -e '<bash-input>' -e '<bash-stdout>' -e '<bash-stderr>' \
151
+ | tail -5 | cut -c1-300
152
+ }
153
+
154
+ _slugify() {
155
+ printf '%s' "$1" \
156
+ | tr '[:upper:]' '[:lower:]' \
157
+ | sed 's/<[^>]*>/ /g; s/[`*_#|:;,.!?()[\]{}"'"'"']/ /g; s/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//' \
158
+ | cut -c1-72 \
159
+ | sed 's/-$//'
160
+ }
161
+
162
+ _prompt_topic_slug() {
163
+ local prompts="$1"
164
+ local line clean lower slug score best_score=0 best_slug=""
165
+
166
+ while IFS= read -r line; do
167
+ [ -z "$line" ] && continue
168
+ lower=$(printf '%s' "$line" | tr '[:upper:]' '[:lower:]')
169
+ case "$lower" in
170
+ start|*request\ interrupted*|*system-reminder*|*local-command-caveat*|*bash-input*|*bash-stdout*|*bash-stderr*|*tool_use_error*)
171
+ continue
172
+ ;;
173
+ esac
174
+
175
+ clean=$(printf '%s' "$line" | sed 's/^>[[:space:]]*//; s/<[^>]*>/ /g; s/[|`*_#]/ /g')
176
+ slug=$(_slugify "$clean")
177
+ [ -z "$slug" ] && continue
178
+ [ "${#slug}" -lt 8 ] && continue
179
+
180
+ score=${#slug}
181
+ case "$slug" in
182
+ *handoff*|*emissary*|*harvest*|*ssh*|*pr*|*review*|*egregore*|*invite*|*deploy*|*release*)
183
+ score=$((score + 20))
184
+ ;;
185
+ esac
186
+
187
+ if [ "$score" -gt "$best_score" ]; then
188
+ best_score="$score"
189
+ best_slug="$slug"
190
+ fi
191
+ done <<EOF
192
+ $prompts
193
+ EOF
194
+
195
+ printf '%s\n' "$best_slug"
196
+ }
197
+
198
+ _branch_topic_slug() {
199
+ local gitsum="$1"
200
+ local author="$2"
201
+ local branch rest score best_score=0 best_slug=""
202
+
203
+ while IFS= read -r branch; do
204
+ [ -z "$branch" ] && continue
205
+ case "$branch" in
206
+ develop|main|master) continue ;;
207
+ dev/"$author"/*) rest="${branch#dev/$author/}"; score=90 ;;
208
+ feature/*|bugfix/*|hotfix/*) rest="${branch#*/}"; score=80 ;;
209
+ dev/*/*) rest="${branch#dev/}"; rest="${rest#*/}"; score=60 ;;
210
+ */*) rest="${branch#*/}"; score=50 ;;
211
+ *) rest="$branch"; score=40 ;;
212
+ esac
213
+
214
+ rest=$(_slugify "$rest")
215
+ [ -z "$rest" ] && continue
216
+ if [ "$score" -gt "$best_score" ]; then
217
+ best_score="$score"
218
+ best_slug="$rest"
219
+ fi
220
+ done <<EOF
221
+ $(printf '%s\n' "$gitsum" | awk -F'`' '/^- `/{print $2}')
222
+ EOF
223
+
224
+ printf '%s\n' "$best_slug"
225
+ }
226
+
227
+ _ripcord_topic_slug() {
228
+ local prompts="$1"
229
+ local gitsum="$2"
230
+ local author="$3"
231
+ local slug
232
+
233
+ slug=$(_prompt_topic_slug "$prompts")
234
+ [ -z "$slug" ] && slug=$(_branch_topic_slug "$gitsum" "$author")
235
+ [ -z "$slug" ] && slug="session"
236
+ printf '%s\n' "$slug"
237
+ }
238
+
239
+ _topic_from_slug() {
240
+ printf '%s\n' "$1" | tr '-' ' '
241
+ }
242
+
243
+ _git_summary() {
244
+ # Branch + dirty/unpushed state for the main checkout and every worktree.
245
+ local wt branch dirty ahead
246
+ git -C "$MAIN_DIR" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | while read -r wt; do
247
+ branch=$(git -C "$wt" branch --show-current 2>/dev/null)
248
+ [ -z "$branch" ] && continue
249
+ dirty=$(git -C "$wt" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
250
+ ahead=$(git -C "$wt" rev-list --count '@{u}..HEAD' 2>/dev/null || echo "?")
251
+ if [ "$dirty" != "0" ] || { [ "$ahead" != "0" ] && [ "$ahead" != "?" ]; }; then
252
+ echo "- \`$branch\` — $dirty uncommitted, $ahead unpushed ($(basename "$wt"))"
253
+ git -C "$wt" status --porcelain 2>/dev/null | head -8 | sed 's/^/ /'
254
+ fi
255
+ done
256
+ }
257
+
258
+ _ripcord() {
259
+ local transcript="$1" base author date_ym date_d slug topic file prompts gitsum
260
+ base=$(basename "$transcript" .jsonl)
261
+
262
+ _capture_enabled || return 0
263
+
264
+ grep -q "$base" "$JOURNAL" 2>/dev/null && return 0
265
+ # Session already ended with ceremony? Then the attendant has no business
266
+ # here. Scan only the tail — a session that merely *read* the /handoff or
267
+ # /save skill text mid-way would otherwise false-positive.
268
+ if tail -c 300000 "$transcript" 2>/dev/null | grep -q -e 'handoff-run.sh' -e 'wraps/'; then
269
+ echo "ripcord-skip $base (explicit handoff/wrap)" >> "$JOURNAL"
270
+ return 0
271
+ fi
272
+
273
+ author=$(_author)
274
+ prompts=$(_last_prompts "$transcript")
275
+ gitsum=$(_git_summary)
276
+
277
+ # Nothing on disk and nothing said — not worth a handoff.
278
+ if [ -z "$gitsum" ] && [ -z "$prompts" ]; then
279
+ echo "ripcord-skip $base (no work found)" >> "$JOURNAL"
280
+ return 0
281
+ fi
282
+
283
+ slug=$(_ripcord_topic_slug "$prompts" "$gitsum" "$author")
284
+ topic=$(_topic_from_slug "$slug")
285
+
286
+ date_ym=$(date '+%Y-%m'); date_d=$(date '+%d')
287
+ file="$MEMORY_DIR/handoffs/$date_ym/${date_d}-${author}-ripcord-${slug}.md"
288
+
289
+ if [ "$DRY_RUN" = "1" ]; then
290
+ log "DRY ripcord → $file"
291
+ _trigger_auto_thread "ripcord"
292
+ return 0
293
+ fi
294
+
295
+ mkdir -p "$(dirname "$file")"
296
+ {
297
+ echo "---"
298
+ echo "date: $(date '+%Y-%m-%d')"
299
+ echo "author: $author"
300
+ echo "to: $author"
301
+ echo "kind: ripcord"
302
+ echo "status: draft"
303
+ echo "topic: $topic"
304
+ echo "---"
305
+ echo ""
306
+ echo "# Ripcord: $topic"
307
+ echo ""
308
+ echo "Automatic capture — this session ended without an explicit handoff."
309
+ echo "Assembled by the attendant from the flight recorder (transcript + git state)."
310
+ echo ""
311
+ if [ -n "$gitsum" ]; then
312
+ echo "## Where the work lives"
313
+ echo ""
314
+ echo "$gitsum"
315
+ echo ""
316
+ fi
317
+ if [ -n "$prompts" ]; then
318
+ echo "## Last things the user asked for"
319
+ echo ""
320
+ echo "$prompts" | sed 's/^/> /'
321
+ echo ""
322
+ fi
323
+ echo "## Pick it up"
324
+ echo ""
325
+ echo "Check out the branch above and run \`/activity\` for surrounding context."
326
+ echo "Session transcript id: \`$base\` ($(date -r "$transcript" '+%Y-%m-%d %H:%M' 2>/dev/null))"
327
+ } > "$file"
328
+
329
+ # Commit + push memory (retry for concurrent pushers; tolerate no remote).
330
+ local rel="${file#"$MEMORY_DIR"/}"
331
+ git -C "$MEMORY_DIR" add "$rel" 2>/dev/null
332
+ git -C "$MEMORY_DIR" commit -q -m "Ripcord: auto-handoff for $author ($slug)" 2>/dev/null
333
+ if git -C "$MEMORY_DIR" remote get-url origin >/dev/null 2>&1; then
334
+ local i
335
+ for i in 1 2 3; do
336
+ git -C "$MEMORY_DIR" pull --rebase origin main --quiet 2>/dev/null && \
337
+ git -C "$MEMORY_DIR" push origin main --quiet 2>/dev/null && break
338
+ sleep 2
339
+ done
340
+ fi
341
+ bash "$MAIN_DIR/bin/index-handoff.sh" "$rel" >/dev/null 2>&1 || true
342
+
343
+ echo "ripcord $base $rel" >> "$JOURNAL"
344
+ log "ripcord → memory/$rel"
345
+ _trigger_auto_thread "ripcord"
346
+ }
347
+
348
+ # --- Auto-push -------------------------------------------------------------
349
+
350
+ _auto_push() {
351
+ _capture_enabled || return 0
352
+ local wt branch dirty ahead upstream
353
+ git -C "$MAIN_DIR" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | while read -r wt; do
354
+ branch=$(git -C "$wt" branch --show-current 2>/dev/null)
355
+ case "$branch" in dev/*|feature/*|bugfix/*) ;; *) continue ;; esac
356
+ # Mid-rebase/merge — never touch.
357
+ local gd; gd=$(git -C "$wt" rev-parse --git-dir 2>/dev/null)
358
+ { [ -d "$gd/rebase-merge" ] || [ -d "$gd/rebase-apply" ] || [ -f "$gd/MERGE_HEAD" ]; } && continue
359
+
360
+ dirty=$(git -C "$wt" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
361
+ # Only act on work that has itself gone quiet (nothing modified recently).
362
+ if [ "$dirty" != "0" ]; then
363
+ local fresh
364
+ fresh=$(git -C "$wt" status --porcelain 2>/dev/null | sed 's/^...//' | head -50 | while read -r f; do
365
+ find "$wt/$f" -mmin "-$STALE_MIN" 2>/dev/null | head -1
366
+ done | head -1)
367
+ [ -n "$fresh" ] && continue
368
+ if [ "$DRY_RUN" = "1" ]; then log "DRY auto-commit $branch ($dirty files, $wt)"; else
369
+ git -C "$wt" add -A 2>/dev/null
370
+ git -C "$wt" commit -q -m "chore(attendant): ripcord auto-save
371
+
372
+ Uncommitted work captured after the session went quiet.
373
+
374
+ Co-Authored-By: Egregore Attendant <noreply@egregore.xyz>" 2>/dev/null
375
+ log "auto-commit $branch ($dirty files)"
376
+ fi
377
+ fi
378
+
379
+ upstream=$(git -C "$wt" rev-parse --abbrev-ref '@{u}' 2>/dev/null || true)
380
+ if [ -z "$upstream" ]; then
381
+ ahead=$(git -C "$wt" rev-list --count origin/develop..HEAD 2>/dev/null || echo 0)
382
+ else
383
+ ahead=$(git -C "$wt" rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)
384
+ fi
385
+ if [ "${ahead:-0}" != "0" ]; then
386
+ if [ "$DRY_RUN" = "1" ]; then log "DRY auto-push $branch ($ahead commits)"; else
387
+ git -C "$wt" push -u origin "$branch" --quiet 2>/dev/null && log "auto-push $branch ($ahead commits)"
388
+ fi
389
+ fi
390
+ done
391
+ }
392
+
393
+ # --- Warm: pre-pay the launch path -------------------------------------------
394
+ # Session-start's cost is network: git fetches + the greeting's graph queries.
395
+ # The attendant pays them in the background every WARM_MIN minutes so launch
396
+ # finds fresh refs (git-sync skips its fetch+wait) and a hot graph cache
397
+ # (context.sh reads answers instead of making round-trips). Runs even while
398
+ # sessions are active — fetch and read-only replay are safe mid-work.
399
+
400
+ WARM_MIN="${ATTENDANT_WARM_MIN:-5}"
401
+ WARM_MARKER="$STATE_DIR/$KEY.warm-ts"
402
+
403
+ # Non-secret fingerprint of the effective graph endpoint + credential, matching
404
+ # bin/graph.sh's resolution order exactly (process env → .env → committed
405
+ # api_url). Scopes the graph cache and the snapshot provenance to the tenant
406
+ # answers actually come from, not just the tenant the committed config names.
407
+ _effective_scope() {
408
+ local url key
409
+ url="${EGREGORE_API_URL:-$(grep '^EGREGORE_API_URL=' "$MAIN_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
410
+ key="${EGREGORE_API_KEY:-$(grep '^EGREGORE_API_KEY=' "$MAIN_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
411
+ [ -n "$url" ] || url=$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)
412
+ echo -n "${url}|${key}" | cksum | cut -d' ' -f1
413
+ }
414
+
415
+ _warm() {
416
+ local now last
417
+ now=$(date +%s)
418
+ last=$(cat "$WARM_MARKER" 2>/dev/null || echo 0)
419
+ # Numeric + not-future guard: a malformed legacy marker must not abort
420
+ # warming, and a future timestamp must not suppress it indefinitely.
421
+ case "$last" in ''|*[!0-9]*) last=0 ;; esac
422
+ [ "$last" -gt "$now" ] && last=0
423
+ [ $(( now - last )) -lt $(( WARM_MIN * 60 )) ] && return 0
424
+ [ "$DRY_RUN" = "1" ] && { log "DRY warm"; return 0; }
425
+
426
+ # --prune keeps remote-tracking refs honest so the launch path can answer
427
+ # "does my branch still exist on origin?" from local refs (worktree stale
428
+ # check in git-sync.sh) instead of a ~2s ls-remote round-trip.
429
+ local _origin_pid _origin_ok="false"
430
+ git -C "$MAIN_DIR" fetch origin --prune --quiet 2>/dev/null &
431
+ _origin_pid=$!
432
+ [ -d "$MEMORY_DIR/.git" ] || [ -f "$MEMORY_DIR/.git" ] && git -C "$MEMORY_DIR" fetch origin --quiet 2>/dev/null &
433
+ local repo
434
+ jq -r '(.repos[]? // empty) | if type == "object" then .name else . end' "$CONFIG" 2>/dev/null | while read -r repo; do
435
+ [ -d "$MAIN_DIR/../$repo/.git" ] && git -C "$MAIN_DIR/../$repo" fetch origin --quiet 2>/dev/null
436
+ done &
437
+ wait "$_origin_pid" 2>/dev/null && _origin_ok="true"
438
+ wait
439
+ # Stamp the marker ONLY when the origin fetch actually succeeded, and stamp
440
+ # it atomically (tmp+mv): the launch path treats a fresh marker as "local
441
+ # refs are authoritative", so a failed fetch must not silence live fetches
442
+ # for ten minutes, and a reader must never catch a half-written timestamp.
443
+ if [ "$_origin_ok" = "true" ]; then
444
+ date +%s > "$WARM_MARKER.tmp" && mv "$WARM_MARKER.tmp" "$WARM_MARKER"
445
+ fi
446
+
447
+ # Replay graph cache entries touched in the last 24h (connected mode only —
448
+ # in local mode graph.sh exits instantly and no cache exists).
449
+ local cache_dir f q p
450
+ # Key must match graph.sh's _CACHE_DIR derivation exactly: repo root plus
451
+ # the effective endpoint/credential fingerprint, so entries never cross
452
+ # tenants when the same checkout is repointed at a different org/API.
453
+ cache_dir="$HOME/.egregore/graph-cache/$(echo -n "${MAIN_DIR}|$(_effective_scope)" | cksum | cut -d' ' -f1)"
454
+ if [ -d "$cache_dir" ]; then
455
+ for f in $(find "$cache_dir" -name '*.json' -mmin -1440 2>/dev/null); do
456
+ q=$(jq -r '.query // empty' "$f" 2>/dev/null)
457
+ p=$(jq -c '.params // {}' "$f" 2>/dev/null)
458
+ [ -n "$q" ] && EGREGORE_GRAPH_CACHE_TTL=1 bash "$MAIN_DIR/bin/graph.sh" query "$q" "$p" >/dev/null 2>&1 || true
459
+ done
460
+ fi
461
+ _prebake_context
462
+ log "warm: refs fetched, graph cache replayed, context baked"
463
+ }
464
+
465
+ # --- Pre-bake the session-start context snapshot -----------------------------
466
+ # Runs the same context gather session-start would (bin/lib/context.sh) and
467
+ # stores the result as a single tar at $STATE_DIR/$KEY.ctx.tar. On boot,
468
+ # context.sh reuses the baked graph-bound results (todos, lifecycle, pulse,
469
+ # metrics, service health) when the bake is <15min old and its provenance
470
+ # matches (author, mode, config), re-running the local collectors live —
471
+ # taking the greeting's graph work off the launch path entirely.
472
+ #
473
+ # One tar + atomic rename, not a directory swap: readers open either the whole
474
+ # old bake or the whole new one, never a half-replaced directory. The tmp name
475
+ # carries $$ so two attendants racing (possible before the run-loop self-check
476
+ # converges them) never write through each other's file.
477
+ _prebake_context() {
478
+ [ -f "$MAIN_DIR/bin/lib/context.sh" ] || return 0
479
+ local snap="$STATE_DIR/$KEY.ctx.tar"
480
+ (
481
+ set +u +e
482
+ unset CTX_SEED_TAR CTX_SEED_DIR # never let a bake seed itself from an old bake
483
+ SCRIPT_DIR="$MAIN_DIR"
484
+ CONFIG="$MAIN_DIR/egregore.json"
485
+ STATE_FILE="$MAIN_DIR/.egregore-state.json"
486
+ ENV_FILE="$MAIN_DIR/.env"
487
+ AUTHOR=$(jq -r '.github_username // empty' "$STATE_FILE" 2>/dev/null)
488
+ [ -n "$AUTHOR" ] || exit 0
489
+ # Canonical mode semantics (config.sh _detect_mode): local when the config
490
+ # says so OR api_url is absent — legacy OSS configs carry no mode key, and
491
+ # a bare '.mode == local' test would bake failed connected health for them.
492
+ LOCAL_MODE="false"
493
+ source "$MAIN_DIR/bin/lib/config.sh" 2>/dev/null || true
494
+ if type _detect_mode >/dev/null 2>&1; then
495
+ [ "$(_detect_mode)" = "local" ] && LOCAL_MODE="true"
496
+ elif ! jq -e '(.mode // "") != "local" and ((.api_url // "") | length > 0)' "$CONFIG" >/dev/null 2>&1; then
497
+ LOCAL_MODE="true"
498
+ fi
499
+ # Capture ALL provenance BEFORE gathering, then publish only if it is
500
+ # unchanged AFTER — a user, config, or credential switch mid-gather would
501
+ # otherwise produce mixed-tenant/mixed-user data labeled with whatever the
502
+ # final values happened to be (TOCTOU).
503
+ _PRE_CONFIG=$(cksum "$CONFIG" 2>/dev/null | cut -d' ' -f1)
504
+ _PRE_SCOPE=$(_effective_scope)
505
+ _PRE_SCHEMA=$(cksum "$MAIN_DIR/bin/lib/context.sh" 2>/dev/null | cut -d' ' -f1)
506
+ # PIN the effective credentials for the entire gather: graph.sh re-reads
507
+ # env/.env in every subprocess, so without pinning, an A→B→A .env flip
508
+ # during the gather could fetch B-tenant data that the pre/post scope
509
+ # samples cannot see. With the pin, every graph call in this bake uses
510
+ # the credentials the provenance stamp names. Empty values stay empty
511
+ # (local mode) — graph.sh treats them as unset.
512
+ export EGREGORE_API_URL="${EGREGORE_API_URL:-$(grep '^EGREGORE_API_URL=' "$MAIN_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
513
+ export EGREGORE_API_KEY="${EGREGORE_API_KEY:-$(grep '^EGREGORE_API_KEY=' "$MAIN_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)}"
514
+ export EGREGORE_GRAPH_CACHE_TTL=600
515
+ source "$MAIN_DIR/bin/lib/context.sh"
516
+ [ -n "$CTX_DIR" ] && [ -d "$CTX_DIR" ] || exit 0
517
+ _POST_AUTHOR=$(jq -r '.github_username // empty' "$STATE_FILE" 2>/dev/null)
518
+ _POST_CONFIG=$(cksum "$CONFIG" 2>/dev/null | cut -d' ' -f1)
519
+ _POST_SCOPE=$(_effective_scope)
520
+ _POST_SCHEMA=$(cksum "$MAIN_DIR/bin/lib/context.sh" 2>/dev/null | cut -d' ' -f1)
521
+ if [ "$_POST_AUTHOR" != "$AUTHOR" ] || [ "$_POST_CONFIG" != "$_PRE_CONFIG" ] \
522
+ || [ "$_POST_SCOPE" != "$_PRE_SCOPE" ] || [ "$_POST_SCHEMA" != "$_PRE_SCHEMA" ]; then
523
+ rm -rf "$CTX_DIR"
524
+ exit 0
525
+ fi
526
+ # Provenance the reader validates before trusting the bake.
527
+ date +%s > "$CTX_DIR/.baked-ts"
528
+ printf '%s\n' "$AUTHOR" > "$CTX_DIR/.baked-author"
529
+ printf '%s\n' "$LOCAL_MODE" > "$CTX_DIR/.baked-local-mode"
530
+ printf '%s\n' "$_PRE_CONFIG" > "$CTX_DIR/.baked-config"
531
+ # Effective endpoint/credential + the context.sh revision that produced
532
+ # this bake: a seed from a different tenant or a different collector
533
+ # schema must never be trusted.
534
+ printf '%s\n' "$_PRE_SCOPE" > "$CTX_DIR/.baked-scope"
535
+ printf '%s\n' "$_PRE_SCHEMA" > "$CTX_DIR/.baked-schema"
536
+ if (cd "$CTX_DIR" && tar -cf "$snap.tmp.$$" .) 2>/dev/null; then
537
+ mv -f "$snap.tmp.$$" "$snap" 2>/dev/null || rm -f "$snap.tmp.$$" 2>/dev/null
538
+ else
539
+ rm -f "$snap.tmp.$$" 2>/dev/null
540
+ fi
541
+ rm -rf "$CTX_DIR"
542
+ ) >/dev/null 2>&1 || true
543
+ # Clean up the directory-format snapshot from the previous iteration
544
+ rm -rf "$STATE_DIR/$KEY.ctx" 2>/dev/null || true
545
+ }
546
+
547
+ # --- Handoff lifecycle: recurring close / nudge / expiry --------------------
548
+ # Connected-mode only. The job itself filters to lifecycle-v1 handoffs with an
549
+ # explicit intent, so legacy unclassified backfill never runs unattended.
550
+
551
+ LIFECYCLE_MIN="${ATTENDANT_LIFECYCLE_MIN:-360}"
552
+ LIFECYCLE_MARKER="$STATE_DIR/$KEY.handoff-lifecycle-ts"
553
+
554
+ _handoff_lifecycle() {
555
+ local now last mode api_url api_key result
556
+ # Read the raw value, not `// true` — jq's `//` returns its right-hand side
557
+ # for null *and false*, so `{"features":{"handoff_lifecycle":false}}` used to
558
+ # resolve to "true" and the opt-out did nothing. Absent key still means on.
559
+ case "$(jq -r '.features.handoff_lifecycle | tojson' "$CONFIG" 2>/dev/null)" in
560
+ null|true) ;;
561
+ *) return 0 ;;
562
+ esac
563
+ [ -f "$MAIN_DIR/bin/handoff-lifecycle-job.sh" ] || return 0
564
+
565
+ mode=$(jq -r '.mode // "connected"' "$CONFIG" 2>/dev/null)
566
+ api_url=$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)
567
+ api_key=$(grep '^EGREGORE_API_KEY=' "$MAIN_DIR/.env" 2>/dev/null | cut -d'=' -f2- || true)
568
+ [ "$mode" = "connected" ] && [ -n "$api_url" ] && [ -n "$api_key" ] || return 0
569
+
570
+ now=$(date +%s)
571
+ last=$(cat "$LIFECYCLE_MARKER" 2>/dev/null || echo 0)
572
+ [ $(( now - last )) -lt $(( LIFECYCLE_MIN * 60 )) ] && return 0
573
+ if [ "$DRY_RUN" = "1" ]; then
574
+ log "DRY handoff-lifecycle"
575
+ return 0
576
+ fi
577
+
578
+ if result=$(bash "$MAIN_DIR/bin/handoff-lifecycle-job.sh" run 2>&1); then
579
+ log "handoff-lifecycle: $(printf '%s' "$result" | jq -c \
580
+ '{transitions,nudges:(.nudges|length)}' 2>/dev/null || echo success)"
581
+ else
582
+ log "handoff-lifecycle failed: $(printf '%s' "$result" | tail -1 | cut -c1-300)"
583
+ fi
584
+ date +%s > "$LIFECYCLE_MARKER"
585
+ }
586
+
587
+ # --- Commands ---------------------------------------------------------------
588
+
589
+ cmd_sweep() {
590
+ _enabled || return 0
591
+ _warm
592
+ _handoff_lifecycle
593
+ if _active_session; then
594
+ log "sweep: session active — standing by"
595
+ return 0
596
+ fi
597
+ local t
598
+ for t in $(_stale_transcripts); do
599
+ _ripcord "$t"
600
+ done
601
+ _auto_push
602
+ _trigger_auto_thread "sweep"
603
+ }
604
+
605
+ cmd_run() {
606
+ _enabled || exit 0
607
+ echo $$ > "$PIDFILE"
608
+ _script_version > "$VERSIONFILE"
609
+ log "attendant up (pid $$, stale=${STALE_MIN}m, $MAIN_DIR)"
610
+ while true; do
611
+ [ -d "$MAIN_DIR" ] || { log "project gone — exiting"; break; }
612
+ # Duplicate-daemon convergence: cmd_ensure's check-then-spawn is lockless,
613
+ # so two concurrent cold starts can both launch a run loop. Whoever owns
614
+ # the pidfile keeps running; the other notices within one cycle and exits
615
+ # without touching shared state.
616
+ [ "$(cat "$PIDFILE" 2>/dev/null)" = "$$" ] || { log "superseded (pid $$) — exiting"; exit 0; }
617
+ cmd_sweep
618
+ sleep 60
619
+ done
620
+ rm -f "$PIDFILE"
621
+ }
622
+
623
+ cmd_ensure() {
624
+ _enabled || exit 0
625
+ if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
626
+ if [ "$(cat "$VERSIONFILE" 2>/dev/null)" = "$(_script_version)" ]; then
627
+ exit 0
628
+ fi
629
+ log "attendant code changed — restarting"
630
+ kill "$(cat "$PIDFILE")" 2>/dev/null || true
631
+ rm -f "$PIDFILE"
632
+ fi
633
+ # Atomic spawn gate: the pidfile check above is check-then-spawn, so two
634
+ # concurrent cold starts could both reach here and launch two daemons that
635
+ # overlap for a full cycle (lifecycle jobs, ripcord, auto-push). mkdir is
636
+ # the portable atomic primitive; a crashed spawner's stale lock breaks
637
+ # after 5 minutes. Losing the race means someone else is spawning — done.
638
+ local _spawn_lock="$STATE_DIR/$KEY.spawn-lock"
639
+ if ! mkdir "$_spawn_lock" 2>/dev/null; then
640
+ local _lock_age
641
+ _lock_age=$(( $(date +%s) - $(stat -f %m "$_spawn_lock" 2>/dev/null || stat -c %Y "$_spawn_lock" 2>/dev/null || echo 0) ))
642
+ # Stale reap by atomic rename, never rmdir+mkdir: two reapers doing
643
+ # remove-then-recreate can alternately delete each other's FRESH lock and
644
+ # both spawn. Exactly one mv succeeds; losing the mv or the follow-up
645
+ # mkdir means someone else owns a fresh lock — back off.
646
+ if [ "$_lock_age" -gt 300 ] && mv "$_spawn_lock" "$_spawn_lock.reap.$$" 2>/dev/null; then
647
+ rm -rf "$_spawn_lock.reap.$$" 2>/dev/null
648
+ mkdir "$_spawn_lock" 2>/dev/null || exit 0
649
+ else
650
+ exit 0
651
+ fi
652
+ fi
653
+ # Re-check under the lock — the racing ensure may already have spawned.
654
+ if ! { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; }; then
655
+ nohup bash "$MAIN_DIR/bin/attendant.sh" run >> "$LOGFILE" 2>&1 &
656
+ disown 2>/dev/null || true
657
+ # Hold the lock until the child's ownership is durably visible (it writes
658
+ # its pidfile first thing in cmd_run). Releasing earlier reopens the race:
659
+ # another ensure passes the pidfile re-check before the child registers
660
+ # and spawns a second daemon whose first sweep overlaps this one.
661
+ local _i _spawned="false"
662
+ for _i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25; do
663
+ if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
664
+ _spawned="true"
665
+ break
666
+ fi
667
+ sleep 0.2
668
+ done
669
+ if [ "$_spawned" != "true" ]; then
670
+ # Child still hasn't registered: KEEP the lock rather than reopen the
671
+ # spawn window — a merely-delayed child may yet come up, and the stale
672
+ # reaper above frees the lock in 5 minutes if it never does. Fail
673
+ # closed: no attendant for a few minutes beats two attendants sweeping.
674
+ log "ensure: spawned child slow to register — holding spawn lock (pid $$)"
675
+ exit 0
676
+ fi
677
+ fi
678
+ rmdir "$_spawn_lock" 2>/dev/null || true
679
+ }
680
+
681
+ cmd_status() {
682
+ if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
683
+ echo "attendant: running (pid $(cat "$PIDFILE"), key $KEY)"
684
+ else
685
+ echo "attendant: not running (key $KEY)"
686
+ fi
687
+ [ -f "$LOGFILE" ] && tail -5 "$LOGFILE"
688
+ }
689
+
690
+ cmd_stop() {
691
+ if [ -f "$PIDFILE" ]; then
692
+ kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null
693
+ rm -f "$PIDFILE" "$VERSIONFILE"
694
+ echo "attendant: stopped"
695
+ else
696
+ echo "attendant: not running"
697
+ fi
698
+ }
699
+
700
+ case "${1:-}" in
701
+ ensure) cmd_ensure ;;
702
+ run) cmd_run ;;
703
+ sweep) cmd_sweep ;;
704
+ status) cmd_status ;;
705
+ stop) cmd_stop ;;
706
+ *) echo "usage: bash bin/attendant.sh {ensure|run|sweep|status|stop}" >&2; exit 1 ;;
707
+ esac