@matt82198/aesop 0.1.0-beta.5 → 0.1.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 (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
@@ -74,6 +74,69 @@ get_untracked_files() {
74
74
  ) | sort -u
75
75
  }
76
76
 
77
+ scan_unpushed_commits() {
78
+ local repo="$1"
79
+ local unpushed_files
80
+ local range_expr
81
+
82
+ # Detect if upstream exists (no-upstream or detached HEAD scenario)
83
+ local upstream
84
+ upstream=$(cd "$repo" 2>/dev/null && git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "")
85
+
86
+ if [ -z "$upstream" ] || [ "$upstream" = "@{u}" ]; then
87
+ # No upstream or detached HEAD: fallback to scanning recent commits (last 20)
88
+ # Use git rev-list to get the base commit 20 commits back, then use git diff
89
+ log "WARN: Repository has no upstream or detached HEAD; falling back to bounded commit range scan for $repo"
90
+ local base
91
+ base=$(cd "$repo" && git rev-list --max-count=20 HEAD 2>/dev/null | tail -1)
92
+ if [ -n "$base" ] && [ "$base" != "$(cd "$repo" && git rev-parse HEAD 2>/dev/null)" ]; then
93
+ # Multiple commits exist: scan from base to HEAD
94
+ range_expr="$base..HEAD"
95
+ unpushed_files=$(cd "$repo" && git diff --name-only "$range_expr" 2>/dev/null | sort -u)
96
+ elif [ -n "$base" ]; then
97
+ # Single commit (or base == HEAD): diff against the empty tree so the
98
+ # range form below (empty_tree..HEAD) still works for a root commit
99
+ # that has no parent to diff against.
100
+ local empty_tree
101
+ empty_tree=$(cd "$repo" && git hash-object -t tree /dev/null 2>/dev/null || echo "4b825dc642cb6eb9a060e54bf8d69288fbee4904")
102
+ range_expr="$empty_tree..HEAD"
103
+ unpushed_files=$(cd "$repo" && git diff --name-only "$range_expr" 2>/dev/null | sort -u)
104
+ else
105
+ unpushed_files=""
106
+ fi
107
+ else
108
+ # Normal case: scan commits not yet pushed to upstream
109
+ range_expr="@{u}..HEAD"
110
+ unpushed_files=$(cd "$repo" && git diff --name-only @{u}..HEAD 2>/dev/null | sort -u)
111
+ fi
112
+
113
+ if [ -z "$unpushed_files" ]; then
114
+ return 0
115
+ fi
116
+
117
+ # P2 wave-25 fix: scan the COMMITTED BLOB content of the unpushed range
118
+ # (via secret_scan.py --range, which reads each changed path's git object
119
+ # at the range's tip commit), NOT the current working-tree copy of these
120
+ # files. A secret committed and then cleaned up in a LATER commit -- but
121
+ # still present in the unpushed range being force-pushed to the backup
122
+ # ref -- was previously invisible here, because the old code read
123
+ # "$repo/$file" straight off disk (which no longer had the secret) instead
124
+ # of the actual blob(s) the backup push carries. --range is also robust to
125
+ # deleted files (git diff --diff-filter=d inside secret_scan.py already
126
+ # excludes them from the file list it scans).
127
+ if [ -f "$AESOP_ROOT/tools/secret_scan.py" ]; then
128
+ if command -v python3 >/dev/null 2>&1; then
129
+ python3 "$AESOP_ROOT/tools/secret_scan.py" --range "$range_expr" --repo "$repo" >/dev/null 2>&1
130
+ return $?
131
+ elif command -v python >/dev/null 2>&1; then
132
+ python "$AESOP_ROOT/tools/secret_scan.py" --range "$range_expr" --repo "$repo" >/dev/null 2>&1
133
+ return $?
134
+ fi
135
+ fi
136
+
137
+ return 0
138
+ }
139
+
77
140
  scan_tracked_files() {
78
141
  local repo="$1"
79
142
  local tracked_files
@@ -81,8 +144,8 @@ scan_tracked_files() {
81
144
  local -a file_paths=()
82
145
  local repo_win
83
146
 
84
- # Convert repo path to Windows format for Python compatibility (Git Bash on Windows)
85
- repo_win=$(cd "$repo" 2>/dev/null && pwd -W 2>/dev/null || echo "$repo")
147
+ # Portable path derivation (not Git-Bash pwd -W only)
148
+ repo_win=$(cd "$repo" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || echo "$repo")
86
149
 
87
150
  tracked_files=$(get_tracked_modifications "$repo")
88
151
  untracked_files=$(get_untracked_files "$repo")
@@ -115,8 +178,14 @@ EOF
115
178
 
116
179
  if [ -f "$AESOP_ROOT/tools/secret_scan.py" ]; then
117
180
  # ITEM 2 fix: use "${file_paths[@]}" to properly quote array elements
118
- python "$AESOP_ROOT/tools/secret_scan.py" "${file_paths[@]}" >/dev/null 2>&1
119
- return $?
181
+ # Defect (a) fix: use python3||python probe instead of bare python (portable to python3-only systems)
182
+ if command -v python3 >/dev/null 2>&1; then
183
+ python3 "$AESOP_ROOT/tools/secret_scan.py" "${file_paths[@]}" >/dev/null 2>&1
184
+ return $?
185
+ elif command -v python >/dev/null 2>&1; then
186
+ python "$AESOP_ROOT/tools/secret_scan.py" "${file_paths[@]}" >/dev/null 2>&1
187
+ return $?
188
+ fi
120
189
  fi
121
190
 
122
191
  return 0
@@ -160,14 +229,27 @@ process_repo() {
160
229
  fi
161
230
  fi
162
231
 
163
- local unpushed=$(git log @{u}.. --oneline 2>/dev/null | wc -l | tr -d ' ')
232
+ # Detect if upstream exists before trying to count unpushed commits
233
+ local upstream
234
+ upstream=$(git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "")
235
+
236
+ local unpushed
237
+ if [ -z "$upstream" ] || [ "$upstream" = "@{u}" ]; then
238
+ # No upstream or detached HEAD: count recent commits instead (last 20)
239
+ log "WARN: Repository has no upstream or detached HEAD; falling back to recent commits count for $name"
240
+ unpushed=$(git log --oneline -20 2>/dev/null | wc -l | tr -d ' ')
241
+ else
242
+ # Normal case: count commits not yet pushed to upstream
243
+ unpushed=$(git log @{u}.. --oneline 2>/dev/null | wc -l | tr -d ' ')
244
+ fi
245
+
164
246
  if [ "$unpushed" -gt 0 ]; then
165
247
  local branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
166
248
  [ "$branch" = "HEAD" ] && branch="$default"
167
249
 
168
250
  if [ "$branch" = "$default" ]; then
169
251
  WIPREF="backup/master-wip-$(date +%Y%m%d)"
170
- if scan_tracked_files "$repo"; then
252
+ if scan_unpushed_commits "$repo"; then
171
253
  if git push -qf origin "HEAD:refs/heads/$WIPREF" 2>/dev/null; then
172
254
  printf 'SNAPSHOTTED\0%s\n' "$name"
173
255
  exit 0
@@ -177,7 +259,7 @@ process_repo() {
177
259
  exit 0
178
260
  fi
179
261
  else
180
- if scan_tracked_files "$repo"; then
262
+ if scan_unpushed_commits "$repo"; then
181
263
  if git push -q origin "$branch" 2>/dev/null; then
182
264
  printf 'PUSHED\0%s\n' "$name"
183
265
  exit 0
@@ -193,52 +275,128 @@ process_repo() {
193
275
  )
194
276
  }
195
277
 
196
- log "=== cycle start ==="
197
- temp_json=$(mktemp)
198
- echo "[" > "$temp_json"
199
- first=1
200
- processed_paths=""
201
-
202
- # Discover all git repos: scan home dot-directories, root directories, dev/ subdirectory
203
- # This pattern includes ~/.* (dot-dirs like .claude), ~/* (home root), ~/dev/* (dev subtree)
204
- for dir in ~/.* ~/* ~/dev/*; do
205
- base=$(basename "$dir")
206
- [ "$base" = "." ] || [ "$base" = ".." ] && continue
207
- [ ! -d "$dir/.git" ] && continue
208
-
209
- # Normalize path to detect duplicates (e.g., .claude vs .claude/)
210
- real_dir=$(cd "$dir" && pwd 2>/dev/null)
211
- if [ -z "$real_dir" ]; then continue; fi
212
- if echo "$processed_paths" | grep -Fq "$real_dir"; then
213
- continue
278
+ main() {
279
+ log "=== cycle start ==="
280
+ temp_json=$(mktemp)
281
+ echo "[" > "$temp_json"
282
+ first=1
283
+ processed_paths=""
284
+
285
+ # AUDIT FIX 1: Check if aesop.config.json exists and has repos array
286
+ repos_to_scan=""
287
+ config_file="$AESOP_ROOT/aesop.config.json"
288
+ if [ -f "$config_file" ]; then
289
+ # Find Python interpreter (portable: prefer python3, fallback to python)
290
+ python_exe=""
291
+ if command -v python3 >/dev/null 2>&1; then
292
+ python_exe="python3"
293
+ elif command -v python >/dev/null 2>&1; then
294
+ python_exe="python"
295
+ fi
296
+
297
+ if [ -n "$python_exe" ]; then
298
+ # Parse repos array from config using Python (not grep)
299
+ # Note: Strip trailing \r (CRLF compatibility) and empty lines
300
+ repos_to_scan=$($python_exe -c "
301
+ import json, sys
302
+ try:
303
+ with open(sys.argv[1], 'r') as f:
304
+ config = json.load(f)
305
+ repos = config.get('repos', [])
306
+ if isinstance(repos, list) and len(repos) > 0:
307
+ for repo in repos:
308
+ if isinstance(repo, dict) and 'path' in repo:
309
+ print(repo['path'])
310
+ except Exception as e:
311
+ pass
312
+ " "$config_file" 2>/dev/null | tr -d '\r')
313
+ fi
214
314
  fi
215
- processed_paths="$processed_paths
315
+
316
+ if [ -n "$repos_to_scan" ]; then
317
+ # AUDIT FIX 1: Use explicit repos from config
318
+ log "Loading repos from config file"
319
+ while IFS= read -r dir; do
320
+ [ -z "$dir" ] && continue
321
+ # Validate repo path exists
322
+ if [ ! -d "$dir/.git" ]; then
323
+ log "WARN: configured repo not found or not a git repo (skipping): $dir"
324
+ continue
325
+ fi
326
+ # Normalize path to detect duplicates
327
+ real_dir=$(cd "$dir" && pwd 2>/dev/null)
328
+ if [ -z "$real_dir" ]; then continue; fi
329
+ if echo "$processed_paths" | grep -Fxq "$real_dir"; then
330
+ continue
331
+ fi
332
+ processed_paths="$processed_paths
216
333
  $real_dir"
217
334
 
218
- if is_touched "$dir"; then
219
- # P0 FIX: Eliminate $() command substitution to preserve NUL bytes in protocol
220
- # Direct redirect maintains NUL delimiters (process_repo emits STATE\0name format)
221
- temp_result=$(mktemp)
222
- process_repo "$dir" > "$temp_result"
223
- # Read first field (state) and second field (name) using NUL delimiter
224
- # Portable solution: use awk with RS="\0" instead of GNU sed \x0 (BSD/macOS compatible)
225
- state=$(awk 'BEGIN{RS="\0"} NR==1 {print}' "$temp_result")
226
- name=$(awk 'BEGIN{RS="\0"} NR==2 {print}' "$temp_result" | tr -d '\n')
227
- rm -f "$temp_result"
228
- [ -z "$state" ] && continue
229
- if [ "$first" = 1 ]; then
230
- first=0
231
- else
232
- echo "," >> "$temp_json"
233
- fi
234
- # P2 FIX: JSON-escape all interpolated values before emitting
235
- # P2 FIX: Portable date format (not GNU-only date -Iseconds)
236
- printf '{"repo":"%s","state":"%s","age":"%s"}' "$(json_escape "$name")" "$(json_escape "$state")" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$temp_json"
237
- log "$state: $name"
335
+ if is_touched "$dir"; then
336
+ temp_result=$(mktemp)
337
+ process_repo "$dir" > "$temp_result"
338
+ state=$(awk 'BEGIN{RS="\0"} NR==1 {print}' "$temp_result")
339
+ name=$(awk 'BEGIN{RS="\0"} NR==2 {print}' "$temp_result" | tr -d '\n')
340
+ rm -f "$temp_result"
341
+ [ -z "$state" ] && continue
342
+ if [ "$first" = 1 ]; then
343
+ first=0
344
+ else
345
+ echo "," >> "$temp_json"
346
+ fi
347
+ printf '{"repo":"%s","state":"%s","age":"%s"}' "$(json_escape "$name")" "$(json_escape "$state")" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$temp_json"
348
+ log "$state: $name"
349
+ fi
350
+ done <<< "$repos_to_scan"
351
+ else
352
+ # AUDIT FIX 1: Fall back to autodiscovery only when no repos configured
353
+ log "No repos configured, using autodiscovery"
354
+ # Discover all git repos: scan home dot-directories, root directories, dev/ subdirectory
355
+ # This pattern includes ~/.* (dot-dirs like .claude), ~/* (home root), ~/dev/* (dev subtree)
356
+ for dir in ~/.* ~/* ~/dev/*; do
357
+ base=$(basename "$dir")
358
+ [ "$base" = "." ] || [ "$base" = ".." ] && continue
359
+ [ ! -d "$dir/.git" ] && continue
360
+
361
+ # Normalize path to detect duplicates (e.g., .claude vs .claude/)
362
+ real_dir=$(cd "$dir" && pwd 2>/dev/null)
363
+ if [ -z "$real_dir" ]; then continue; fi
364
+ if echo "$processed_paths" | grep -Fxq "$real_dir"; then
365
+ continue
366
+ fi
367
+ processed_paths="$processed_paths
368
+ $real_dir"
369
+
370
+ if is_touched "$dir"; then
371
+ # P0 FIX: Eliminate $() command substitution to preserve NUL bytes in protocol
372
+ # Direct redirect maintains NUL delimiters (process_repo emits STATE\0name format)
373
+ temp_result=$(mktemp)
374
+ process_repo "$dir" > "$temp_result"
375
+ # Read first field (state) and second field (name) using NUL delimiter
376
+ # Portable solution: use awk with RS="\0" instead of GNU sed \x0 (BSD/macOS compatible)
377
+ state=$(awk 'BEGIN{RS="\0"} NR==1 {print}' "$temp_result")
378
+ name=$(awk 'BEGIN{RS="\0"} NR==2 {print}' "$temp_result" | tr -d '\n')
379
+ rm -f "$temp_result"
380
+ [ -z "$state" ] && continue
381
+ if [ "$first" = 1 ]; then
382
+ first=0
383
+ else
384
+ echo "," >> "$temp_json"
385
+ fi
386
+ # P2 FIX: JSON-escape all interpolated values before emitting
387
+ # P2 FIX: Portable date format (not GNU-only date -Iseconds)
388
+ printf '{"repo":"%s","state":"%s","age":"%s"}' "$(json_escape "$name")" "$(json_escape "$state")" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$temp_json"
389
+ log "$state: $name"
390
+ fi
391
+ done
238
392
  fi
239
- done
240
393
 
241
- echo "" >> "$temp_json"
242
- echo "]" >> "$temp_json"
243
- mv "$temp_json" "$REPOS_STATUS"
244
- log "=== cycle end ==="
394
+ echo "" >> "$temp_json"
395
+ echo "]" >> "$temp_json"
396
+ mv "$temp_json" "$REPOS_STATUS"
397
+ log "=== cycle end ==="
398
+ }
399
+
400
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
401
+ main "$@"
402
+ fi
@@ -5,12 +5,30 @@
5
5
  #
6
6
  # Configuration: export AESOP_ROOT=/path/to/aesop before running (defaults to script directory's parent).
7
7
  # Testing: export AESOP_WATCHDOG_CYCLE_CMD to override backup-fleet.sh invocation
8
+ #
9
+ # Kill switch (wave-26 safety brake): if AESOP_ROOT/state/.HALT exists (see
10
+ # tools/halt.py), every cycle logs "HALTED: <reason>" and skips all work — no
11
+ # backup, no push, no scan — until cleared via `python tools/halt.py --clear`.
8
12
 
9
13
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
14
  AESOP_ROOT="${AESOP_ROOT:-$(dirname "$SCRIPT_DIR")}"
11
15
  MODE="${1:-daemon}"
12
16
  LOCK_DIR="$AESOP_ROOT/state/.watchdog-lock"
13
17
  LOCK_STALE_THRESHOLD=300
18
+ HALT_SENTINEL="$AESOP_ROOT/state/.HALT"
19
+
20
+ # Resolve Python interpreter (portable: prefer python3, fallback to python)
21
+ PYTHON_EXE=""
22
+ if command -v python3 >/dev/null 2>&1; then
23
+ PYTHON_EXE="python3"
24
+ elif command -v python >/dev/null 2>&1; then
25
+ PYTHON_EXE="python"
26
+ fi
27
+
28
+ # Resolve conductor3 directory (sibling of AESOP_ROOT)
29
+ CONDUCTOR_ROOT="$(dirname "$AESOP_ROOT")/conductor3"
30
+ MONITOR_HB_FILE="$CONDUCTOR_ROOT/monitor/.monitor-heartbeat"
31
+ MONITOR_HB_STALE_THRESHOLD=600
14
32
 
15
33
  # Atomic lock acquire: mkdir is atomic (POSIX guarantees)
16
34
  # Returns 0 if lock acquired, 1 if held by another process, 2 if stale lock reclaimed
@@ -30,20 +48,64 @@ acquire_lock() {
30
48
  fi
31
49
 
32
50
  # Lock directory already exists; check if it's stale
33
- if [ -d "$lock_dir" ] && [ -f "$lock_dir/timestamp" ]; then
34
- local lock_mtime=$(cat "$lock_dir/timestamp" 2>/dev/null || echo 0)
35
- local now=$(date +%s)
36
- local lock_age=$((now - lock_mtime))
37
-
38
- if [ "$lock_age" -gt "$stale_threshold" ]; then
39
- # Lock is stale; try to reclaim it
40
- rm -rf "$lock_dir" 2>/dev/null || true
41
- if mkdir "$lock_dir" 2>/dev/null; then
42
- date +%s > "$lock_dir/timestamp" 2>/dev/null
43
- echo $$ > "$lock_dir/pid" 2>/dev/null
44
- echo "watchdog lock was stale (${lock_age}s) — reclaimed." >&2
45
- return 2
51
+ # AUDIT FIX 2: Verify both timestamp AND pid files exist before the stale comparison
52
+ if [ -d "$lock_dir" ]; then
53
+ # Try to read pid file to check if process is still running
54
+ local lock_pid=""
55
+ if [ -f "$lock_dir/pid" ]; then
56
+ lock_pid=$(cat "$lock_dir/pid" 2>/dev/null)
57
+ fi
58
+
59
+ # Check if both required files exist
60
+ if [ -f "$lock_dir/timestamp" ] && [ -f "$lock_dir/pid" ] && [ -n "$lock_pid" ]; then
61
+ # Both files present and pid is readable - use timestamp for stale check
62
+ local lock_mtime=$(cat "$lock_dir/timestamp" 2>/dev/null || echo 0)
63
+ local now=$(date +%s)
64
+ local lock_age=$((now - lock_mtime))
65
+
66
+ if [ "$lock_age" -gt "$stale_threshold" ]; then
67
+ # Lock is stale; try to reclaim it
68
+ rm -rf "$lock_dir" 2>/dev/null || true
69
+ if mkdir "$lock_dir" 2>/dev/null; then
70
+ date +%s > "$lock_dir/timestamp" 2>/dev/null
71
+ echo $$ > "$lock_dir/pid" 2>/dev/null
72
+ echo "watchdog lock was stale (${lock_age}s) — reclaimed." >&2
73
+ return 2
74
+ fi
75
+ fi
76
+ else
77
+ # AUDIT FIX 2: Timestamp or pid file missing, or pid is empty - lock is incomplete
78
+ # Reclaim only if we can verify it's abandoned: dead pid OR stale timestamp
79
+ local should_reclaim=0
80
+
81
+ # Case 1: pid file exists and is readable - check if process is running
82
+ if [ -f "$lock_dir/pid" ] && [ -n "$lock_pid" ]; then
83
+ if ! kill -0 "$lock_pid" 2>/dev/null; then
84
+ should_reclaim=1
85
+ fi
86
+ fi
87
+
88
+ # Case 2: no pid file, but timestamp exists and is old (definitely abandoned)
89
+ if [ $should_reclaim -eq 0 ] && [ -f "$lock_dir/timestamp" ]; then
90
+ local lock_mtime=$(cat "$lock_dir/timestamp" 2>/dev/null || echo 0)
91
+ local now=$(date +%s)
92
+ local lock_age=$((now - lock_mtime))
93
+ if [ "$lock_age" -gt "$stale_threshold" ]; then
94
+ should_reclaim=1
95
+ fi
46
96
  fi
97
+
98
+ if [ $should_reclaim -eq 1 ]; then
99
+ # Lock is abandoned - reclaim it
100
+ rm -rf "$lock_dir" 2>/dev/null || true
101
+ if mkdir "$lock_dir" 2>/dev/null; then
102
+ date +%s > "$lock_dir/timestamp" 2>/dev/null
103
+ echo $$ > "$lock_dir/pid" 2>/dev/null
104
+ echo "watchdog lock was stale (incomplete) — reclaimed." >&2
105
+ return 2
106
+ fi
107
+ fi
108
+ # If lock is incomplete but fresh, hold it (process might be in progress)
47
109
  fi
48
110
  fi
49
111
 
@@ -62,59 +124,143 @@ release_lock() {
62
124
  fi
63
125
  }
64
126
 
65
- # Try to acquire lock (applies to both --once and daemon modes)
66
- acquire_lock "$LOCK_DIR" "$LOCK_STALE_THRESHOLD"
67
- lock_result=$?
127
+ # Check monitor heartbeat staleness; log to fleet-backup.log if missing or >600s old
128
+ check_monitor_staleness() {
129
+ local hb_file="$1"
130
+ local stale_threshold="$2"
131
+ local log_file="$3"
132
+ if [ -z "$hb_file" ] || [ -z "$log_file" ]; then
133
+ return
134
+ fi
135
+ if [ ! -f "$hb_file" ]; then
136
+ echo "[$(date '+%F %T')] SIGNAL: monitor heartbeat missing ($hb_file)" >> "$log_file"
137
+ return
138
+ fi
139
+ local hb_epoch=$(cat "$hb_file" 2>/dev/null || echo 0)
140
+ if [ -z "$hb_epoch" ] || [ "$hb_epoch" = "0" ]; then
141
+ echo "[$(date '+%F %T')] SIGNAL: monitor heartbeat empty/unreadable" >> "$log_file"
142
+ return
143
+ fi
144
+ local now=$(date +%s)
145
+ local hb_age=$((now - hb_epoch))
146
+ if [ "$hb_age" -gt "$stale_threshold" ]; then
147
+ echo "[$(date '+%F %T')] SIGNAL: monitor heartbeat stale (${hb_age}s > ${stale_threshold}s threshold)" >> "$log_file"
148
+ fi
149
+ }
68
150
 
69
- if [ $lock_result -eq 1 ]; then
70
- echo "watchdog already running not starting a duplicate."
71
- exit 0
72
- fi
151
+ # Kill switch check (wave-26 safety brake). Returns 0 (bash true) and logs
152
+ # "HALTED: <reason>" if $AESOP_ROOT/state/.HALT exists; returns 1 otherwise.
153
+ # Never runs backup/push/scan work when halted — caller must skip the cycle.
154
+ check_halt() {
155
+ local log_file="$1"
156
+ if [ ! -f "$HALT_SENTINEL" ]; then
157
+ return 1
158
+ fi
73
159
 
74
- echo "==================================================================="
75
- echo " FLEET WATCHDOG DAEMON · backup + ensure-push + scan / 150s"
76
- echo " logs: $AESOP_ROOT/state/FLEET-BACKUP.log · Ctrl-C to stop"
77
- echo "==================================================================="
78
- echo "[$(date '+%F %T')] === watchdog daemon (shell) STARTED ===" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
79
- trap "release_lock \"$LOCK_DIR\"; echo \"[$(date '+%F %T')] === watchdog daemon (shell) STOPPED ===\" >> \"$AESOP_ROOT/state/FLEET-BACKUP.log\"; echo \"stopped.\"; exit 0" INT TERM
80
-
81
- # Allow override of backup cycle command (for testing)
82
- # Use array to safely handle paths with spaces (P1 fix)
83
- if [ -n "$AESOP_WATCHDOG_CYCLE_CMD" ]; then
84
- # Override: run as-is through bash -c
85
- CYCLE_CMD_ARRAY=("bash" "-c" "$AESOP_WATCHDOG_CYCLE_CMD")
86
- else
87
- # Default: array form for proper quoting
88
- CYCLE_CMD_ARRAY=("bash" "$AESOP_ROOT/daemons/backup-fleet.sh")
89
- fi
160
+ local reason="halted (reason unavailable)"
161
+ if [ -n "$PYTHON_EXE" ]; then
162
+ local parsed
163
+ parsed=$("$PYTHON_EXE" -c '
164
+ import json, sys
165
+ try:
166
+ with open(sys.argv[1], encoding="utf-8") as f:
167
+ data = json.load(f)
168
+ r = data.get("reason")
169
+ if r:
170
+ print(r)
171
+ except Exception:
172
+ pass
173
+ ' "$HALT_SENTINEL" 2>/dev/null)
174
+ if [ -n "$parsed" ]; then
175
+ reason="$parsed"
176
+ fi
177
+ fi
90
178
 
91
- if [ "$MODE" = "--once" ]; then
92
- full_out=$("${CYCLE_CMD_ARRAY[@]}" 2>&1)
93
- cmd_exit=$?
94
- echo "$full_out"
95
- if [ $cmd_exit -ne 0 ]; then
96
- err_msg="[$(date '+%F %T')] ERROR: cycle #1 failed with exit code $cmd_exit"
97
- echo "$err_msg" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
98
- echo "[ERROR: exit $cmd_exit]" >&2
179
+ echo "HALTED: $reason"
180
+ if [ -n "$log_file" ]; then
181
+ echo "[$(date '+%F %T')] HALTED: $reason" >> "$log_file"
99
182
  fi
100
- python "$AESOP_ROOT/tools/alert_bridge.py" --scan || true
101
- release_lock "$LOCK_DIR"
102
- exit $cmd_exit
103
- fi
183
+ return 0
184
+ }
185
+
186
+ # Main execution — guarded below so sourcing this file (e.g. from tests, to
187
+ # reuse acquire_lock/release_lock/check_halt) never runs a cycle as a
188
+ # side effect of the source itself.
189
+ main() {
190
+ # Try to acquire lock (applies to both --once and daemon modes)
191
+ acquire_lock "$LOCK_DIR" "$LOCK_STALE_THRESHOLD"
192
+ lock_result=$?
104
193
 
105
- n=0
106
- while true; do
107
- n=$((n+1))
108
- full_out=$("${CYCLE_CMD_ARRAY[@]}" 2>&1)
109
- cmd_exit=$?
110
- if [ $cmd_exit -eq 0 ]; then
111
- out=$(echo "$full_out" | tail -2)
112
- printf '%s cycle #%d\n%s\n' "$(date '+%H:%M:%S')" "$n" "$out"
194
+ if [ $lock_result -eq 1 ]; then
195
+ echo "watchdog already running — not starting a duplicate."
196
+ exit 0
197
+ fi
198
+
199
+ echo "==================================================================="
200
+ echo " FLEET WATCHDOG DAEMON · backup + ensure-push + scan / 150s"
201
+ echo " logs: $AESOP_ROOT/state/FLEET-BACKUP.log · Ctrl-C to stop"
202
+ echo "==================================================================="
203
+ echo "[$(date '+%F %T')] === watchdog daemon (shell) STARTED ===" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
204
+ trap "release_lock \"$LOCK_DIR\"; echo \"[$(date '+%F %T')] === watchdog daemon (shell) STOPPED ===\" >> \"$AESOP_ROOT/state/FLEET-BACKUP.log\"; echo \"stopped.\"; exit 0" INT TERM
205
+
206
+ # Allow override of backup cycle command (for testing)
207
+ # Use array to safely handle paths with spaces (P1 fix)
208
+ if [ -n "$AESOP_WATCHDOG_CYCLE_CMD" ]; then
209
+ # Override: run as-is through bash -c
210
+ CYCLE_CMD_ARRAY=("bash" "-c" "$AESOP_WATCHDOG_CYCLE_CMD")
113
211
  else
114
- echo "[$(date '+%F %T')] ERROR: cycle #$n failed with exit code $cmd_exit" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
115
- out=$(echo "$full_out" | tail -2)
116
- printf '%s cycle #%d [ERROR: exit %d]\n%s\n' "$(date '+%H:%M:%S')" "$n" "$cmd_exit" "$out"
212
+ # Default: array form for proper quoting
213
+ CYCLE_CMD_ARRAY=("bash" "$AESOP_ROOT/daemons/backup-fleet.sh")
117
214
  fi
118
- python "$AESOP_ROOT/tools/alert_bridge.py" --scan || true
119
- sleep 150
120
- done
215
+
216
+ if [ "$MODE" = "--once" ]; then
217
+ if check_halt "$AESOP_ROOT/state/FLEET-BACKUP.log"; then
218
+ release_lock "$LOCK_DIR"
219
+ exit 0
220
+ fi
221
+ full_out=$("${CYCLE_CMD_ARRAY[@]}" 2>&1)
222
+ cmd_exit=$?
223
+ echo "$full_out"
224
+ if [ $cmd_exit -ne 0 ]; then
225
+ err_msg="[$(date '+%F %T')] ERROR: cycle #1 failed with exit code $cmd_exit"
226
+ echo "$err_msg" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
227
+ echo "[ERROR: exit $cmd_exit]" >&2
228
+ fi
229
+ if [ -n "$PYTHON_EXE" ]; then
230
+ "$PYTHON_EXE" "$AESOP_ROOT/tools/alert_bridge.py" --scan || true
231
+ fi
232
+ release_lock "$LOCK_DIR"
233
+ exit $cmd_exit
234
+ fi
235
+
236
+ n=0
237
+ while true; do
238
+ n=$((n+1))
239
+ if check_halt "$AESOP_ROOT/state/FLEET-BACKUP.log"; then
240
+ sleep 150
241
+ continue
242
+ fi
243
+ full_out=$("${CYCLE_CMD_ARRAY[@]}" 2>&1)
244
+ cmd_exit=$?
245
+ if [ $cmd_exit -eq 0 ]; then
246
+ out=$(echo "$full_out" | tail -2)
247
+ printf '%s cycle #%d\n%s\n' "$(date '+%H:%M:%S')" "$n" "$out"
248
+ else
249
+ echo "[$(date '+%F %T')] ERROR: cycle #$n failed with exit code $cmd_exit" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
250
+ out=$(echo "$full_out" | tail -2)
251
+ printf '%s cycle #%d [ERROR: exit %d]\n%s\n' "$(date '+%H:%M:%S')" "$n" "$cmd_exit" "$out"
252
+ fi
253
+ check_monitor_staleness "$MONITOR_HB_FILE" "$MONITOR_HB_STALE_THRESHOLD" "$AESOP_ROOT/state/FLEET-BACKUP.log"
254
+ if [ -n "$PYTHON_EXE" ]; then
255
+ "$PYTHON_EXE" "$AESOP_ROOT/tools/alert_bridge.py" --scan || true
256
+ fi
257
+ sleep 150
258
+ done
259
+ }
260
+
261
+ # Execution guard: only run a cycle when this script is executed directly,
262
+ # not when it is sourced (e.g. `source run-watchdog.sh` in a test harness to
263
+ # reuse acquire_lock/release_lock/check_halt without triggering a cycle).
264
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
265
+ main "$@"
266
+ fi