instar 1.2.54 → 1.2.55

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.
@@ -4,167 +4,306 @@
4
4
  # Prevents session exit when autonomous mode is active.
5
5
  # Feeds the goal and task list back to continue working.
6
6
  #
7
- # SESSION-SCOPED: Only blocks the session that activated autonomous mode.
8
- # Other sessions on the same machine pass through unaffected.
7
+ # TOPIC-KEYED OWNERSHIP (primary): the autonomous job is identified by the
8
+ # TOPIC it serves, not by the Claude session UUID. The topic is a stable
9
+ # "street address" — when a session hits the memory limit and restarts, its
10
+ # UUID rotates (a new badge) but instar respawns it into the SAME tmux session
11
+ # name, and the topic-session registry still maps that name to the same topic.
12
+ # So whoever is running in the topic's session is recognized as continuing the
13
+ # job, restart or no restart. This fixes the silent-failure bug where a restart
14
+ # rotated the UUID, mismatched the state file, and let autonomy die unnoticed.
9
15
  #
10
- # RESPECTS:
11
- # - Session isolation (only blocks the autonomous session)
12
- # - Emergency stop signals (user says "stop everything")
13
- # - Duration expiry
14
- # - Genuine completion (all tasks done, promise output)
16
+ # LIVENESS-GATED BACKSTOP (rare): when topic resolution is unavailable (no tmux,
17
+ # or the topic isn't in the registry) the hook falls back to session-id matching.
18
+ # A session-id mismatch is then gated by a liveness check on the recorded owner
19
+ # (is its transcript still growing?) — a dead owner is adopted, a live one is
20
+ # left alone. This is the demoted role of the old liveness idea: a thin edge
21
+ # guard, not the main mechanism.
22
+ #
23
+ # RECOVERY NOTE: on an actual restart-and-resume (topic verified, but the
24
+ # session UUID changed) the hook emits ONE user-facing one-line note and an
25
+ # audit record, then records the new UUID so the note never repeats.
26
+ #
27
+ # RESPECTS: emergency stop, duration expiry, genuine completion (promise).
15
28
 
16
- set -euo pipefail
29
+ set -uo pipefail # NOTE: -e intentionally omitted; field lookups for optional
30
+ # frontmatter keys are expected to "fail" (grep finds nothing)
31
+ # and must not abort the hook. Each critical step guards itself.
17
32
 
18
33
  # Read hook input from stdin
19
34
  HOOK_INPUT=$(cat)
20
35
 
21
- # Check if autonomous mode is active
22
36
  STATE_FILE=".instar/autonomous-state.local.md"
37
+ REGISTRY_FILE=".instar/topic-session-registry.json"
38
+ RECOVERY_AUDIT=".instar/autonomous-recovery.jsonl"
39
+ LIVENESS_SECS="${INSTAR_AUTONOMOUS_LIVENESS_SECS:-120}"
23
40
 
24
41
  if [[ ! -f "$STATE_FILE" ]]; then
25
42
  # No active autonomous session — allow exit
26
43
  exit 0
27
44
  fi
28
45
 
29
- # Parse YAML frontmatter
46
+ # Parse YAML frontmatter (between the first two --- lines)
30
47
  FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE")
31
48
 
32
- ACTIVE=$(echo "$FRONTMATTER" | grep '^active:' | sed 's/active: *//')
49
+ # Safe frontmatter field reader never trips pipefail when a key is absent.
50
+ fm_get() {
51
+ local key="$1"
52
+ printf '%s\n' "$FRONTMATTER" | grep "^${key}:" | head -1 | sed "s/^${key}: *//" | tr -d '"' || true
53
+ }
54
+
55
+ ACTIVE=$(fm_get active)
33
56
  if [[ "$ACTIVE" != "true" ]]; then
34
57
  exit 0
35
58
  fi
36
59
 
37
- # SESSION ISOLATION: Only block the session that started autonomous mode.
38
- # Uses self-bootstrapping: if no session_id in state file yet, the FIRST
39
- # session to trigger the hook claims it. All other sessions see a mismatch.
40
- STATE_SESSION=$(echo "$FRONTMATTER" | grep '^session_id:' | sed 's/^session_id: *//' | tr -d '"' || true)
60
+ # ── Inputs from the hook ──────────────────────────────────────────────
61
+ HOOK_SESSION=$(printf '%s' "$HOOK_INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
62
+ TRANSCRIPT_PATH=$(printf '%s' "$HOOK_INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "")
41
63
 
42
- # VALIDATE: If state has a session_id but it's not a valid UUID, treat as empty.
43
- # Claude sometimes writes a custom string instead of the real $CLAUDE_CODE_SESSION_ID.
44
- # Non-UUID values will never match the real hook session_id, causing the hook to
45
- # fail-open and allow premature exit. By clearing invalid values, we fall through
46
- # to self-bootstrap, which captures the REAL session_id from the first hook call.
64
+ # If hook has no session_id fail OPEN (unknown context, don't trap)
65
+ if [[ -z "$HOOK_SESSION" ]]; then
66
+ echo "⚠️ Autonomous mode: No session_id in hook input fail-open (allowing exit)" >&2
67
+ exit 0
68
+ fi
69
+
70
+ # ── State fields ──────────────────────────────────────────────────────
71
+ REPORT_TOPIC=$(fm_get report_topic)
72
+ # Channel that owns this job — recovery note routes here. Default telegram for
73
+ # back-compat (state files written before channel-neutral delivery existed).
74
+ REPORT_CHANNEL=$(fm_get report_channel)
75
+ [[ -z "$REPORT_CHANNEL" ]] && REPORT_CHANNEL="telegram"
76
+ STATE_SESSION=$(fm_get session_id)
77
+ ITERATION=$(fm_get iteration)
78
+ DURATION_SECONDS=$(fm_get duration_seconds)
79
+ STARTED_AT=$(fm_get started_at)
80
+ COMPLETION_PROMISE=$(fm_get completion_promise)
81
+
82
+ # Validate recorded session_id is a real UUID. Claude sometimes writes a custom
83
+ # string instead of $CLAUDE_CODE_SESSION_ID; non-UUID values are treated as
84
+ # empty so the session-match backstop self-bootstraps from the real UUID.
47
85
  UUID_REGEX='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
48
86
  if [[ -n "$STATE_SESSION" ]] && ! [[ "$STATE_SESSION" =~ $UUID_REGEX ]]; then
49
- echo "[autonomous] Invalid session_id in state file (not UUID): '$STATE_SESSION' — falling back to self-bootstrap" >&2
87
+ echo "[autonomous] Invalid session_id in state file (not UUID): '$STATE_SESSION' — clearing" >&2
50
88
  STATE_SESSION=""
51
89
  fi
52
90
 
53
- HOOK_SESSION=$(echo "$HOOK_INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
54
-
55
- # If hook has no session_id fail OPEN (unknown context, don't trap)
56
- if [[ -z "$HOOK_SESSION" ]]; then
57
- echo "⚠️ Autonomous mode: No session_id in hook input — fail-open (allowing exit)" >&2
58
- exit 0
91
+ # ── Resolve MY topic (the stable address) ─────────────────────────────
92
+ # Test/override seam: INSTAR_HOOK_TMUX_SESSION (if the var is set at all, even
93
+ # empty, it wins empty means "no tmux"). INSTAR_HOOK_NO_TMUX=1 forces empty.
94
+ resolve_my_tmux() {
95
+ if [[ "${INSTAR_HOOK_NO_TMUX:-}" == "1" ]]; then
96
+ echo ""
97
+ return
98
+ fi
99
+ if [[ -n "${INSTAR_HOOK_TMUX_SESSION+x}" ]]; then
100
+ echo "${INSTAR_HOOK_TMUX_SESSION}"
101
+ return
102
+ fi
103
+ tmux display-message -p '#S' 2>/dev/null || echo ""
104
+ }
105
+ MY_TMUX=$(resolve_my_tmux)
106
+
107
+ # Reverse-lookup: which tmux session owns REPORT_TOPIC per the registry?
108
+ OWNER_TMUX=""
109
+ if [[ -n "$REPORT_TOPIC" ]] && [[ -f "$REGISTRY_FILE" ]]; then
110
+ OWNER_TMUX=$(REPORT_TOPIC="$REPORT_TOPIC" python3 -c "
111
+ import json, os
112
+ try:
113
+ reg = json.load(open('$REGISTRY_FILE'))
114
+ print((reg.get('topicToSession') or {}).get(os.environ['REPORT_TOPIC'], ''))
115
+ except Exception:
116
+ print('')
117
+ " 2>/dev/null || echo "")
59
118
  fi
60
119
 
61
- # SELF-BOOTSTRAP: If state has no session_id yet, claim it from this hook call.
62
- # The first session to fire the hook becomes the autonomous session.
63
- if [[ -z "$STATE_SESSION" ]]; then
64
- # Atomic claim: write session_id to state file
65
- TEMP_FILE="${STATE_FILE}.claim.$$"
66
- sed "s/^session_id:.*/session_id: \"${HOOK_SESSION}\"/" "$STATE_FILE" > "$TEMP_FILE"
67
- mv "$TEMP_FILE" "$STATE_FILE"
68
- STATE_SESSION="$HOOK_SESSION"
69
- echo "[autonomous] Session $HOOK_SESSION claimed autonomous mode" >&2
120
+ # ── Ownership decision ────────────────────────────────────────────────
121
+ # OWNER=true means: this session IS the autonomous worker; block its exit.
122
+ # OWNER_METHOD records how we decided (topic | session | bootstrap | adopt-dead).
123
+ OWNER="false"
124
+ OWNER_METHOD=""
125
+ RESTART_DETECTED="false"
126
+
127
+ if [[ -n "$MY_TMUX" ]] && [[ -n "$OWNER_TMUX" ]]; then
128
+ # Topic resolution is conclusive.
129
+ if [[ "$MY_TMUX" == "$OWNER_TMUX" ]]; then
130
+ OWNER="true"; OWNER_METHOD="topic"
131
+ # Restart? Recorded UUID is a real UUID but differs from the live one.
132
+ if [[ -n "$STATE_SESSION" ]] && [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then
133
+ RESTART_DETECTED="true"
134
+ fi
135
+ else
136
+ # Registry says topic T is served by a different session → not me.
137
+ exit 0
138
+ fi
139
+ else
140
+ # ── Backstop: topic unresolved (no tmux, or topic not in registry) ──
141
+ if [[ -z "$STATE_SESSION" ]]; then
142
+ # Self-bootstrap: first session to fire claims the job.
143
+ OWNER="true"; OWNER_METHOD="bootstrap"
144
+ elif [[ "$STATE_SESSION" == "$HOOK_SESSION" ]]; then
145
+ OWNER="true"; OWNER_METHOD="session"
146
+ else
147
+ # Session-id mismatch with no topic signal. Gate on recorded owner liveness.
148
+ OWNER_ALIVE="false"
149
+ if [[ -n "$TRANSCRIPT_PATH" ]]; then
150
+ OWNER_TRANSCRIPT="$(dirname "$TRANSCRIPT_PATH")/${STATE_SESSION}.jsonl"
151
+ if [[ -f "$OWNER_TRANSCRIPT" ]]; then
152
+ NOW_E=$(date +%s)
153
+ # GNU stat (-c %Y) first — Linux is the common host (and CI). BSD stat
154
+ # (-f %m) second for macOS. NOTE: GNU `stat -f` is filesystem mode and
155
+ # SUCCEEDS with non-numeric output, so BSD-first would mask the GNU path
156
+ # on Linux and feed garbage into the arithmetic below. The numeric guard
157
+ # is the backstop: a non-numeric mtime is treated as 0 (very old → dead).
158
+ MTIME=$(stat -c %Y "$OWNER_TRANSCRIPT" 2>/dev/null || stat -f %m "$OWNER_TRANSCRIPT" 2>/dev/null || echo 0)
159
+ [[ "$MTIME" =~ ^[0-9]+$ ]] || MTIME=0
160
+ AGE=$(( NOW_E - MTIME ))
161
+ if [[ $MTIME -gt 0 ]] && [[ $AGE -lt $LIVENESS_SECS ]]; then
162
+ OWNER_ALIVE="true"
163
+ fi
164
+ fi
165
+ fi
166
+ if [[ "$OWNER_ALIVE" == "true" ]]; then
167
+ # A genuinely different, live session owns this — don't steal.
168
+ exit 0
169
+ fi
170
+ # Recorded owner is dead/unknown → adopt the job.
171
+ OWNER="true"; OWNER_METHOD="adopt-dead"; RESTART_DETECTED="true"
172
+ fi
70
173
  fi
71
174
 
72
- # Different session allow exit (fail-open for non-autonomous sessions)
73
- if [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then
175
+ if [[ "$OWNER" != "true" ]]; then
74
176
  exit 0
75
177
  fi
76
178
 
77
- # Same session — this IS the autonomous session, proceed with block logic
78
-
79
- ITERATION=$(echo "$FRONTMATTER" | grep '^iteration:' | sed 's/iteration: *//')
80
- DURATION_SECONDS=$(echo "$FRONTMATTER" | grep '^duration_seconds:' | sed 's/duration_seconds: *//')
81
- STARTED_AT=$(echo "$FRONTMATTER" | grep '^started_at:' | sed 's/started_at: *//' | tr -d '"')
82
- COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | tr -d '"')
83
- REPORT_TOPIC=$(echo "$FRONTMATTER" | grep '^report_topic:' | sed 's/report_topic: *//' | tr -d '"')
179
+ # ── This IS the autonomous session. Terminal checks first. ────────────
84
180
 
85
181
  # Validate iteration
86
182
  if [[ ! "$ITERATION" =~ ^[0-9]+$ ]]; then
87
183
  echo "⚠️ Autonomous mode: State file corrupted (bad iteration)" >&2
88
- rm "$STATE_FILE"
184
+ rm -f "$STATE_FILE"
89
185
  exit 0
90
186
  fi
91
187
 
92
- # Check duration expiry
188
+ # Duration expiry. Fail-SAFE: if started_at can't be parsed (START_EPOCH falls
189
+ # back to 0/empty), do NOT expire — an unparseable timestamp must never cause a
190
+ # premature exit (that is the very failure class this hook exists to prevent).
191
+ REMAINING_MIN=""
93
192
  if [[ "$DURATION_SECONDS" =~ ^[0-9]+$ ]] && [[ $DURATION_SECONDS -gt 0 ]]; then
94
- START_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$STARTED_AT" +%s 2>/dev/null || date -d "$STARTED_AT" +%s 2>/dev/null || echo "0")
95
- NOW_EPOCH=$(date +%s)
96
- ELAPSED=$(( NOW_EPOCH - START_EPOCH ))
97
- if [[ $ELAPSED -ge $DURATION_SECONDS ]]; then
98
- echo "⏰ Autonomous mode: Duration expired ($ELAPSED seconds elapsed)."
99
- echo " Session is free to exit."
100
- rm "$STATE_FILE"
101
- exit 0
193
+ START_EPOCH=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$STARTED_AT" +%s 2>/dev/null || date -d "$STARTED_AT" +%s 2>/dev/null || echo "0")
194
+ if [[ "$START_EPOCH" =~ ^[0-9]+$ ]] && [[ $START_EPOCH -gt 0 ]]; then
195
+ NOW_EPOCH=$(date +%s)
196
+ ELAPSED=$(( NOW_EPOCH - START_EPOCH ))
197
+ if [[ $ELAPSED -ge $DURATION_SECONDS ]]; then
198
+ echo " Autonomous mode: Duration expired ($ELAPSED seconds elapsed)."
199
+ echo " Session is free to exit."
200
+ rm -f "$STATE_FILE"
201
+ exit 0
202
+ fi
203
+ REMAINING=$(( DURATION_SECONDS - ELAPSED ))
204
+ REMAINING_MIN=$(( REMAINING / 60 ))
205
+ else
206
+ echo "[autonomous] started_at unparseable ('$STARTED_AT') — skipping duration-expiry check (fail-safe: keep running)" >&2
102
207
  fi
103
- REMAINING=$(( DURATION_SECONDS - ELAPSED ))
104
- REMAINING_MIN=$(( REMAINING / 60 ))
105
208
  fi
106
209
 
107
- # Check for emergency stop (look in recent messages)
108
- # The MessageSentinel handles this at the messaging layer, but also check here
210
+ # Emergency stop
109
211
  if [[ -f ".instar/autonomous-emergency-stop" ]]; then
110
212
  echo "🛑 Autonomous mode: Emergency stop detected."
111
- rm "$STATE_FILE"
213
+ rm -f "$STATE_FILE"
112
214
  rm -f ".instar/autonomous-emergency-stop"
113
215
  exit 0
114
216
  fi
115
217
 
116
- # Get transcript and check for completion promise
117
- TRANSCRIPT_PATH=$(echo "$HOOK_INPUT" | jq -r '.transcript_path' 2>/dev/null || echo "")
118
-
218
+ # Completion promise (genuine completion)
119
219
  if [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then
120
- # Check last assistant message for completion promise
121
220
  LAST_LINE=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 || echo "")
122
-
123
221
  if [[ -n "$LAST_LINE" ]]; then
124
- LAST_OUTPUT=$(echo "$LAST_LINE" | jq -r '
125
- .message.content |
126
- map(select(.type == "text")) |
127
- map(.text) |
128
- join("\n")
222
+ LAST_OUTPUT=$(printf '%s' "$LAST_LINE" | jq -r '
223
+ .message.content | map(select(.type == "text")) | map(.text) | join("\n")
129
224
  ' 2>/dev/null || echo "")
130
-
131
- # Check for completion promise in <promise> tags
132
225
  if [[ -n "$COMPLETION_PROMISE" ]] && [[ "$COMPLETION_PROMISE" != "null" ]]; then
133
- PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g' 2>/dev/null || echo "")
134
-
226
+ PROMISE_TEXT=$(printf '%s' "$LAST_OUTPUT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g' 2>/dev/null || echo "")
135
227
  if [[ -n "$PROMISE_TEXT" ]] && [[ "$PROMISE_TEXT" = "$COMPLETION_PROMISE" ]]; then
136
228
  echo "✅ Autonomous mode: Completion promise detected — <promise>$COMPLETION_PROMISE</promise>"
137
229
  echo " Session is free to exit. Good work!"
138
- rm "$STATE_FILE"
230
+ rm -f "$STATE_FILE"
139
231
  exit 0
140
232
  fi
141
233
  fi
142
234
  fi
143
235
  fi
144
236
 
145
- # Not complete block exit and feed task list back
237
+ # ── Not terminal: we are continuing. Handle restart-resume recovery note. ──
238
+ # Always reconcile the recorded session_id to the live one (so the backstop and
239
+ # restart-detection stay accurate). Emit the one-line note exactly once, only
240
+ # when a real restart was detected (topic-verified or dead-owner adoption).
241
+ record_session_id() {
242
+ local new_id="$1"
243
+ local tmp="${STATE_FILE}.sid.$$"
244
+ if grep -q '^session_id:' "$STATE_FILE"; then
245
+ sed "s/^session_id:.*/session_id: \"${new_id}\"/" "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE"
246
+ fi
247
+ }
248
+
249
+ # Channel-neutral delivery seam. The hook does NOT assume Telegram — it routes
250
+ # the note to whatever channel owns the job. Telegram is wired here; the other
251
+ # channels are owned by the unified notification layer tracked in the Channel
252
+ # Parity initiative. Either way the durable audit record below is the source of
253
+ # truth, so a not-yet-wired channel never silently misfires to Telegram.
254
+ deliver_recovery_note() {
255
+ local channel="$1" target="$2" text="$3"
256
+ [[ -z "$target" ]] && return 0
257
+ case "$channel" in
258
+ telegram)
259
+ if [[ -x ".instar/scripts/telegram-reply.sh" ]]; then
260
+ printf '%s\n' "$text" | .instar/scripts/telegram-reply.sh "$target" >/dev/null 2>&1 || true
261
+ elif [[ -x ".claude/scripts/telegram-reply.sh" ]]; then
262
+ printf '%s\n' "$text" | .claude/scripts/telegram-reply.sh "$target" >/dev/null 2>&1 || true
263
+ fi
264
+ ;;
265
+ *)
266
+ # slack | whatsapp | imessage | future — delivered by the unified notify
267
+ # layer (Channel Parity initiative). Recorded to the audit trail above.
268
+ echo "[autonomous] recovery note for channel '$channel' recorded to audit; live delivery pending the Channel Parity initiative" >&2
269
+ ;;
270
+ esac
271
+ }
272
+
273
+ if [[ "$RESTART_DETECTED" == "true" ]] && [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then
274
+ ITER_LABEL="${ITERATION:-?}"
275
+ NOTE="Heads up — my session restarted mid-run and I've picked the autonomous job back up (topic ${REPORT_TOPIC:-?}, iteration ${ITER_LABEL}). No action needed."
276
+ # Durable, channel-neutral audit record (always).
277
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
278
+ printf '{"ts":"%s","event":"restart-resume","channel":"%s","topic":"%s","oldSession":"%s","newSession":"%s","method":"%s","iteration":"%s"}\n' \
279
+ "$TS" "$REPORT_CHANNEL" "${REPORT_TOPIC:-}" "$STATE_SESSION" "$HOOK_SESSION" "$OWNER_METHOD" "$ITER_LABEL" >> "$RECOVERY_AUDIT" 2>/dev/null || true
280
+ # Best-effort user-facing delivery via the channel that owns the job.
281
+ deliver_recovery_note "$REPORT_CHANNEL" "$REPORT_TOPIC" "$NOTE"
282
+ echo "[autonomous] restart-resume: channel=$REPORT_CHANNEL topic=${REPORT_TOPIC:-?} old=$STATE_SESSION new=$HOOK_SESSION method=$OWNER_METHOD" >&2
283
+ fi
284
+
285
+ # Reconcile recorded session_id to live (covers restart, bootstrap, adopt).
286
+ if [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then
287
+ record_session_id "$HOOK_SESSION"
288
+ fi
289
+
290
+ # ── Continue the job: increment iteration, feed the task back. ────────
146
291
  NEXT_ITERATION=$((ITERATION + 1))
147
292
 
148
- # Extract prompt (everything after closing ---)
149
293
  PROMPT_TEXT=$(awk '/^---$/{i++; next} i>=2' "$STATE_FILE")
150
-
151
294
  if [[ -z "$PROMPT_TEXT" ]]; then
152
295
  echo "⚠️ Autonomous mode: State file has no task content" >&2
153
- rm "$STATE_FILE"
296
+ rm -f "$STATE_FILE"
154
297
  exit 0
155
298
  fi
156
299
 
157
- # Update iteration counter
158
- TEMP_FILE="${STATE_FILE}.tmp.$$"
159
- sed "s/^iteration: .*/iteration: $NEXT_ITERATION/" "$STATE_FILE" > "$TEMP_FILE"
160
- mv "$TEMP_FILE" "$STATE_FILE"
300
+ TEMP_FILE="${STATE_FILE}.iter.$$"
301
+ sed "s/^iteration: .*/iteration: $NEXT_ITERATION/" "$STATE_FILE" > "$TEMP_FILE" && mv "$TEMP_FILE" "$STATE_FILE"
161
302
 
162
303
  # ── Progress Report Check ──
163
- # Check if it's time to send a progress report
164
- REPORT_INTERVAL=$(echo "$FRONTMATTER" | grep '^report_interval:' | sed 's/report_interval: *//' | tr -d '"')
165
- LAST_REPORT_AT=$(echo "$FRONTMATTER" | grep '^last_report_at:' | sed 's/last_report_at: *//' | tr -d '"')
304
+ REPORT_INTERVAL=$(fm_get report_interval)
305
+ LAST_REPORT_AT=$(fm_get last_report_at)
166
306
 
167
- # Convert report interval to seconds
168
307
  REPORT_INTERVAL_SECS=1800 # default 30 minutes
169
308
  if [[ "$REPORT_INTERVAL" =~ ^([0-9]+)m$ ]]; then
170
309
  REPORT_INTERVAL_SECS=$(( ${BASH_REMATCH[1]} * 60 ))
@@ -174,37 +313,30 @@ fi
174
313
 
175
314
  REPORT_DUE="false"
176
315
  NOW_EPOCH=$(date +%s)
177
-
178
- if [[ -z "$LAST_REPORT_AT" ]] || [[ "$LAST_REPORT_AT" == "null" ]] || [[ "$LAST_REPORT_AT" == '""' ]]; then
179
- # No report sent yet — due if we've been running for at least one interval
316
+ if [[ -z "$LAST_REPORT_AT" ]] || [[ "$LAST_REPORT_AT" == "null" ]]; then
180
317
  if [[ -n "$STARTED_AT" ]]; then
181
- START_EPOCH_R=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$STARTED_AT" +%s 2>/dev/null || date -d "$STARTED_AT" +%s 2>/dev/null || echo "0")
318
+ START_EPOCH_R=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$STARTED_AT" +%s 2>/dev/null || date -d "$STARTED_AT" +%s 2>/dev/null || echo "0")
182
319
  ELAPSED_SINCE_START=$(( NOW_EPOCH - START_EPOCH_R ))
183
320
  if [[ $ELAPSED_SINCE_START -ge $REPORT_INTERVAL_SECS ]]; then
184
321
  REPORT_DUE="true"
185
322
  fi
186
323
  fi
187
324
  else
188
- # Check time since last report
189
- LAST_REPORT_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST_REPORT_AT" +%s 2>/dev/null || date -d "$LAST_REPORT_AT" +%s 2>/dev/null || echo "0")
325
+ LAST_REPORT_EPOCH=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST_REPORT_AT" +%s 2>/dev/null || date -d "$LAST_REPORT_AT" +%s 2>/dev/null || echo "0")
190
326
  ELAPSED_SINCE_REPORT=$(( NOW_EPOCH - LAST_REPORT_EPOCH ))
191
327
  if [[ $ELAPSED_SINCE_REPORT -ge $REPORT_INTERVAL_SECS ]]; then
192
328
  REPORT_DUE="true"
193
329
  fi
194
330
  fi
195
331
 
196
- # If report is due, update last_report_at in state file
197
332
  REPORT_DIRECTIVE=""
198
333
  if [[ "$REPORT_DUE" == "true" ]]; then
199
334
  REPORT_NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
200
- # Update or add last_report_at in frontmatter
201
335
  if grep -q '^last_report_at:' "$STATE_FILE"; then
202
- TEMP_FILE2="${STATE_FILE}.tmp2.$$"
203
- sed "s/^last_report_at: .*/last_report_at: \"$REPORT_NOW\"/" "$STATE_FILE" > "$TEMP_FILE2"
204
- mv "$TEMP_FILE2" "$STATE_FILE"
336
+ TEMP_FILE2="${STATE_FILE}.rpt.$$"
337
+ sed "s/^last_report_at: .*/last_report_at: \"$REPORT_NOW\"/" "$STATE_FILE" > "$TEMP_FILE2" && mv "$TEMP_FILE2" "$STATE_FILE"
205
338
  else
206
- # Add last_report_at before the closing ---
207
- TEMP_FILE2="${STATE_FILE}.tmp2.$$"
339
+ TEMP_FILE2="${STATE_FILE}.rpt.$$"
208
340
  sed "0,/^---$/! { /^---$/i\\
209
341
  last_report_at: \"$REPORT_NOW\"
210
342
  }" "$STATE_FILE" > "$TEMP_FILE2" 2>/dev/null && mv "$TEMP_FILE2" "$STATE_FILE" || true
@@ -10,6 +10,7 @@ set -euo pipefail
10
10
  GOAL=""
11
11
  DURATION="4h"
12
12
  REPORT_TOPIC=""
13
+ REPORT_CHANNEL="telegram" # channel that owns this job; recovery note routes here (telegram|slack|whatsapp|imessage)
13
14
  LEVEL_UP="false"
14
15
  TASKS=""
15
16
  COMPLETION_PROMISE=""
@@ -29,6 +30,10 @@ while [[ $# -gt 0 ]]; do
29
30
  REPORT_TOPIC="$2"
30
31
  shift 2
31
32
  ;;
33
+ --report-channel)
34
+ REPORT_CHANNEL="$2"
35
+ shift 2
36
+ ;;
32
37
  --level-up)
33
38
  LEVEL_UP="true"
34
39
  shift
@@ -102,6 +107,7 @@ duration_seconds: $DURATION_SECONDS
102
107
  started_at: "$STARTED_AT"
103
108
  end_at: "$END_AT"
104
109
  report_topic: "$REPORT_TOPIC"
110
+ report_channel: "$REPORT_CHANNEL"
105
111
  report_interval: "$REPORT_INTERVAL"
106
112
  last_report_at: ""
107
113
  level_up: $LEVEL_UP
@@ -157,6 +157,25 @@ export declare class PostUpdateMigrator {
157
157
  * longer matches the stock fingerprint is left untouched.
158
158
  */
159
159
  private migrateBuildSkillMethodology;
160
+ /**
161
+ * Update the deployed autonomous stop hook to the topic-keyed version.
162
+ *
163
+ * The old hook keyed autonomous-session ownership on the Claude session UUID;
164
+ * a memory-limit restart rotated the UUID, mismatched the state file, and let
165
+ * the still-running session exit — autonomy died silently. The new hook keys
166
+ * on the TOPIC (a stable address that survives restarts), demotes session-id
167
+ * matching to a liveness-gated backstop, and emits a one-line recovery note.
168
+ *
169
+ * installAutonomousSkill() is install-if-missing, so existing agents never get
170
+ * this through init — a dedicated migration is the only path (Migration Parity
171
+ * Standard, "updating existing skill content").
172
+ *
173
+ * Idempotent + conservative: only re-copies the bundled hook when the installed
174
+ * copy (a) lacks the new "topic-session-registry" marker AND (b) still looks
175
+ * like the stock hook (contains "Autonomous Mode Stop Hook"). A customized hook
176
+ * that no longer matches the stock fingerprint is left untouched.
177
+ */
178
+ private migrateAutonomousStopHookTopicKeyed;
160
179
  /**
161
180
  * Deploy any missing built-in skills (e.g., guardian job skills added after initial setup).
162
181
  * Non-destructive — only writes SKILL.md files that don't already exist.
@@ -1 +1 @@
1
- {"version":3,"file":"PostUpdateMigrator.d.ts","sourceRoot":"","sources":["../../src/core/PostUpdateMigrator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"PostUpdateMigrator.d.ts","sourceRoot":"","sources":["../../src/core/PostUpdateMigrator.ts"],"names":[],"mappings":"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"}
@@ -142,6 +142,7 @@ export class PostUpdateMigrator {
142
142
  this.autoMigrateLegacyJobsJson(result);
143
143
  this.migrateSkillPortHardcoding(result);
144
144
  this.migrateBuildSkillMethodology(result);
145
+ this.migrateAutonomousStopHookTopicKeyed(result);
145
146
  this.migrateSelfKnowledgeTree(result);
146
147
  this.migrateSoulMd(result);
147
148
  this.migrateAgentMdSections(result);
@@ -1103,6 +1104,50 @@ export class PostUpdateMigrator {
1103
1104
  result.errors.push(`skills/build/SKILL.md methodology migration: ${err instanceof Error ? err.message : String(err)}`);
1104
1105
  }
1105
1106
  }
1107
+ /**
1108
+ * Update the deployed autonomous stop hook to the topic-keyed version.
1109
+ *
1110
+ * The old hook keyed autonomous-session ownership on the Claude session UUID;
1111
+ * a memory-limit restart rotated the UUID, mismatched the state file, and let
1112
+ * the still-running session exit — autonomy died silently. The new hook keys
1113
+ * on the TOPIC (a stable address that survives restarts), demotes session-id
1114
+ * matching to a liveness-gated backstop, and emits a one-line recovery note.
1115
+ *
1116
+ * installAutonomousSkill() is install-if-missing, so existing agents never get
1117
+ * this through init — a dedicated migration is the only path (Migration Parity
1118
+ * Standard, "updating existing skill content").
1119
+ *
1120
+ * Idempotent + conservative: only re-copies the bundled hook when the installed
1121
+ * copy (a) lacks the new "topic-session-registry" marker AND (b) still looks
1122
+ * like the stock hook (contains "Autonomous Mode Stop Hook"). A customized hook
1123
+ * that no longer matches the stock fingerprint is left untouched.
1124
+ */
1125
+ migrateAutonomousStopHookTopicKeyed(result) {
1126
+ try {
1127
+ const deployed = path.join(this.config.projectDir, '.claude', 'skills', 'autonomous', 'hooks', 'autonomous-stop-hook.sh');
1128
+ if (!fs.existsSync(deployed))
1129
+ return; // installAutonomousSkill handles fresh installs
1130
+ const current = fs.readFileSync(deployed, 'utf8');
1131
+ if (current.includes('topic-session-registry'))
1132
+ return; // already topic-keyed — idempotent
1133
+ if (!current.includes('Autonomous Mode Stop Hook')) {
1134
+ result.skipped.push('skills/autonomous/hooks/autonomous-stop-hook.sh: customized — left untouched');
1135
+ return;
1136
+ }
1137
+ const bundled = path.join(__dirname, '..', '..', '.claude', 'skills', 'autonomous', 'hooks', 'autonomous-stop-hook.sh');
1138
+ if (!fs.existsSync(bundled))
1139
+ return;
1140
+ const next = fs.readFileSync(bundled, 'utf8');
1141
+ if (next.includes('topic-session-registry')) {
1142
+ fs.writeFileSync(deployed, next);
1143
+ fs.chmodSync(deployed, 0o755);
1144
+ result.upgraded.push('skills/autonomous/hooks/autonomous-stop-hook.sh (topic-keyed ownership + recovery note)');
1145
+ }
1146
+ }
1147
+ catch (err) {
1148
+ result.errors.push(`autonomous stop hook topic-keying migration: ${err instanceof Error ? err.message : String(err)}`);
1149
+ }
1150
+ }
1106
1151
  /**
1107
1152
  * Deploy any missing built-in skills (e.g., guardian job skills added after initial setup).
1108
1153
  * Non-destructive — only writes SKILL.md files that don't already exist.