@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5

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 (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -0
@@ -0,0 +1,36 @@
1
+ # daemons/ — Watchdog daemon
2
+
3
+ **Purpose**: Long-running backup and secret-scan daemon for fleet-wide repo safety.
4
+
5
+ ## Files
6
+
7
+ - **run-watchdog.sh** (1.7K): Interactive daemon supervisor; spawns backup-fleet.sh every 150s, maintains heartbeat guard (200s dedupe window), logs to FLEET-BACKUP.log. Calls tools/alert_bridge.py --scan after each cycle to post HIGH/CRITICAL alerts to Slack/Discord (opt-in). Traps INT/TERM cleanly.
8
+ - **backup-fleet.sh** (5K): Core backup worker; discovers repos (~/.*, ~/*, ~/dev/*), stashes uncommitted work to backup/* branches, pushes unpushed commits, scans tracked files for secrets. Blocks push if secret-scan fails (marks BLOCKED state).
9
+
10
+ ## Contracts
11
+
12
+ **Env vars consumed**: `AESOP_ROOT` (defaults to `.`) — root of project; prepends to all state/ and tools/ paths.
13
+
14
+ **State files written** (git-ignored):
15
+ - `$AESOP_ROOT/state/.watchdog-heartbeat` — epoch seconds; dedupe guard (200s window).
16
+ - `$AESOP_ROOT/state/.watchdog-repos.json` — per-cycle snapshot: [{repo, state: CLEAN|PUSHED|SNAPSHOTTED|BLOCKED, age}].
17
+ - `$AESOP_ROOT/state/FLEET-BACKUP.log` — append-only; cycle start/end, repo statuses, secret-scan blocks.
18
+ - `$AESOP_ROOT/state/SECURITY-ALERTS.log` — append-only; security alerts (read by alert_bridge.py to post to webhooks).
19
+ - `$AESOP_ROOT/state/.alert-bridge-cursor` — line number of last sent alert (ensures idempotent alert dispatch).
20
+
21
+ **Exit behaviors**: run-watchdog exits 0 always (trap handles clean shutdown). backup-fleet exits 0 even on secret-scan block (marks repo BLOCKED, continues).
22
+
23
+ **Cadence**: 150s cycle; heartbeat dedupe within 200s.
24
+
25
+ ## Invariants & Gotchas
26
+
27
+ 1. **Single-instance guard (atomic lock)**: run-watchdog.sh uses atomic mkdir-based lockfile (`.watchdog-lock/`) to prevent concurrent daemons.
28
+ - Atomic acquire: `mkdir $LOCK_DIR` is guaranteed atomic on POSIX systems (returns 0 only to first caller).
29
+ - Stale-lock recovery: Crashed holder won't wedge daemon forever; lock older than 300s is reclaimed atomically.
30
+ - Guards both daemon mode and `--once` mode (no bypass).
31
+ 2. **Testing override**: Set `AESOP_WATCHDOG_CYCLE_CMD` env var to replace backup-fleet.sh invocation (allows tests to use mock cycle without running real backup).
32
+ 3. **CRLF-safe, no line continuations**: Use POSIX-safe heredocs; never add `\` for line wrap.
33
+ 4. **Secret-scan gate**: `scan_tracked_files()` calls `$AESOP_ROOT/tools/secret_scan.py` on staged/modified files; non-0 exit blocks push, marks repo BLOCKED.
34
+ 5. **Append-only logs**: FLEET-BACKUP.log only grows; rotate via tools/rotate_logs.py.
35
+ 6. **Path dedup via realpath**: Avoids processing symlinked repos or dot-dir aliases twice.
36
+ 7. **Alert Bridge integration** (NEW wave-14): After each backup-fleet.sh cycle, run-watchdog.sh calls `python $AESOP_ROOT/tools/alert_bridge.py --scan || true` to post HIGH/CRITICAL security alerts and heartbeat staleness to Slack/Discord. No-op if webhook_url null/absent in aesop.config.json (feature opt-in). Uses cursor file for idempotent dispatch (no re-sends). See tools/alert_bridge.py for details.
@@ -3,16 +3,49 @@ set -uo pipefail
3
3
  # Backup fleet repos: stash uncommitted work, push unpushed commits to backup branches.
4
4
  # Runs every 150s from run-watchdog.sh. Abort on secret-scan failures.
5
5
  # Improvements: dot-directory discovery, path dedup, tracked-files-only secret scanning.
6
+ # P2 FIX: JSON escaping for repo names and NUL-delimited internal protocol.
6
7
 
7
8
  AESOP_ROOT="${AESOP_ROOT:-.}"
8
9
  HEARTBEAT="$AESOP_ROOT/state/.watchdog-heartbeat"
9
10
  LOG="$AESOP_ROOT/state/FLEET-BACKUP.log"
10
11
  REPOS_STATUS="$AESOP_ROOT/state/.watchdog-repos.json"
11
12
 
13
+ # P2 FIX: script-global temp file handles (cycle-status JSON build + per-repo
14
+ # NUL-protocol scratch file), cleaned up unconditionally via trap below so an
15
+ # interrupted cycle (INT/TERM) or unexpected error never leaks mktemp files
16
+ # over the daemon's long-running lifecycle. Initialized empty so `rm -f`
17
+ # under `set -u` is always safe even if the trap fires before mktemp runs.
18
+ temp_json=""
19
+ temp_result=""
20
+ cleanup_temp_files() {
21
+ rm -f "$temp_json" "$temp_result"
22
+ }
23
+ trap cleanup_temp_files EXIT INT TERM
24
+
12
25
  date +%s > "$HEARTBEAT" 2>/dev/null
13
26
  ts() { date '+%Y-%m-%d %H:%M:%S'; }
14
27
  log() { echo "[$(ts)] $*" | tee -a "$LOG"; }
15
28
 
29
+ # P2 FIX: JSON escape function for safe string interpolation in JSON
30
+ # Escapes backslash, quote, and control characters (newline, tab, carriage return, etc.)
31
+ json_escape() {
32
+ local s="$1"
33
+ # Escape backslash first (must be first to avoid double-escaping)
34
+ s="${s//\\/\\\\}"
35
+ # Escape double quote
36
+ s="${s//\"/\\\"}"
37
+ # Escape newline
38
+ s="${s//$'\n'/\\n}"
39
+ # Escape carriage return
40
+ s="${s//$'\r'/\\r}"
41
+ # Escape tab
42
+ s="${s//$'\t'/\\t}"
43
+ # Escape backspace, form feed, and other C0 controls
44
+ s="${s//$'\b'/\\b}"
45
+ s="${s//$'\f'/\\f}"
46
+ printf '%s' "$s"
47
+ }
48
+
16
49
  is_touched() {
17
50
  local repo="$1"
18
51
  [ ! -d "$repo/.git" ] && return 1
@@ -33,31 +66,60 @@ get_tracked_modifications() {
33
66
  ) | sort -u
34
67
  }
35
68
 
69
+ get_untracked_files() {
70
+ local repo="$1"
71
+ (
72
+ cd "$repo" || return 1
73
+ git ls-files --others --exclude-standard 2>/dev/null
74
+ ) | sort -u
75
+ }
76
+
36
77
  scan_tracked_files() {
37
78
  local repo="$1"
38
79
  local tracked_files
39
- local file_paths=""
80
+ local untracked_files
81
+ local -a file_paths=()
82
+ local repo_win
83
+
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")
86
+
40
87
  tracked_files=$(get_tracked_modifications "$repo")
88
+ untracked_files=$(get_untracked_files "$repo")
41
89
 
42
- if [ -z "$tracked_files" ]; then
43
- return 0
90
+ # Collect tracked modifications
91
+ if [ -n "$tracked_files" ]; then
92
+ while IFS= read -r file; do
93
+ if [ -n "$file" ] && [ -f "$repo/$file" ]; then
94
+ file_paths+=("$repo_win/$file")
95
+ fi
96
+ done <<EOF
97
+ $tracked_files
98
+ EOF
44
99
  fi
45
100
 
46
- while IFS= read -r file; do
47
- if [ -n "$file" ] && [ -f "$repo/$file" ]; then
48
- file_paths="$file_paths $repo/$file"
49
- fi
50
- done <<EOF
51
- $tracked_files
101
+ # Collect untracked files (ITEM 1 fix)
102
+ if [ -n "$untracked_files" ]; then
103
+ while IFS= read -r file; do
104
+ if [ -n "$file" ] && [ -f "$repo/$file" ]; then
105
+ file_paths+=("$repo_win/$file")
106
+ fi
107
+ done <<EOF
108
+ $untracked_files
52
109
  EOF
110
+ fi
53
111
 
54
- if [ -z "$file_paths" ]; then
112
+ if [ ${#file_paths[@]} -eq 0 ]; then
55
113
  return 0
56
114
  fi
57
115
 
58
116
  if [ -f "$AESOP_ROOT/tools/secret_scan.py" ]; then
59
- python "$AESOP_ROOT/tools/secret_scan.py" $file_paths >/dev/null 2>&1
117
+ # 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 $?
60
120
  fi
121
+
122
+ return 0
61
123
  }
62
124
 
63
125
  get_default_branch() {
@@ -88,11 +150,11 @@ process_repo() {
88
150
  WIPREF="backup/wip-$(date +%Y%m%d)"
89
151
  if scan_tracked_files "$repo"; then
90
152
  if git push -qf origin "$COMMIT:refs/heads/$WIPREF" 2>/dev/null; then
91
- printf 'SNAPSHOTTED|%s\n' "$name"
153
+ printf 'SNAPSHOTTED\0%s\n' "$name"
92
154
  exit 0
93
155
  fi
94
156
  else
95
- printf 'BLOCKED|%s\n' "$name"
157
+ printf 'BLOCKED\0%s\n' "$name"
96
158
  exit 0
97
159
  fi
98
160
  fi
@@ -107,27 +169,27 @@ process_repo() {
107
169
  WIPREF="backup/master-wip-$(date +%Y%m%d)"
108
170
  if scan_tracked_files "$repo"; then
109
171
  if git push -qf origin "HEAD:refs/heads/$WIPREF" 2>/dev/null; then
110
- printf 'SNAPSHOTTED|%s\n' "$name"
172
+ printf 'SNAPSHOTTED\0%s\n' "$name"
111
173
  exit 0
112
174
  fi
113
175
  else
114
- printf 'BLOCKED|%s\n' "$name"
176
+ printf 'BLOCKED\0%s\n' "$name"
115
177
  exit 0
116
178
  fi
117
179
  else
118
180
  if scan_tracked_files "$repo"; then
119
181
  if git push -q origin "$branch" 2>/dev/null; then
120
- printf 'PUSHED|%s\n' "$name"
182
+ printf 'PUSHED\0%s\n' "$name"
121
183
  exit 0
122
184
  fi
123
185
  else
124
- printf 'BLOCKED|%s\n' "$name"
186
+ printf 'BLOCKED\0%s\n' "$name"
125
187
  exit 0
126
188
  fi
127
189
  fi
128
190
  fi
129
191
 
130
- printf 'CLEAN|%s\n' "$name"
192
+ printf 'CLEAN\0%s\n' "$name"
131
193
  )
132
194
  }
133
195
 
@@ -154,15 +216,24 @@ for dir in ~/.* ~/* ~/dev/*; do
154
216
  $real_dir"
155
217
 
156
218
  if is_touched "$dir"; then
157
- result=$(process_repo "$dir")
158
- IFS="|" read -r state name <<< "$result"
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"
159
228
  [ -z "$state" ] && continue
160
229
  if [ "$first" = 1 ]; then
161
230
  first=0
162
231
  else
163
232
  echo "," >> "$temp_json"
164
233
  fi
165
- printf '{"repo":"%s","state":"%s","age":"%s"}' "$name" "$state" "$(date -Iseconds)" >> "$temp_json"
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"
166
237
  log "$state: $name"
167
238
  fi
168
239
  done
@@ -3,16 +3,71 @@
3
3
  # Backs up committed + uncommitted fleet work and scans for security issues every 150s.
4
4
  # Usage: run-watchdog.sh [--once]
5
5
  #
6
- # Configuration: export AESOP_ROOT=/path/to/aesop before running, or edit default below.
6
+ # Configuration: export AESOP_ROOT=/path/to/aesop before running (defaults to script directory's parent).
7
+ # Testing: export AESOP_WATCHDOG_CYCLE_CMD to override backup-fleet.sh invocation
7
8
 
8
- AESOP_ROOT="${AESOP_ROOT:-.}"
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ AESOP_ROOT="${AESOP_ROOT:-$(dirname "$SCRIPT_DIR")}"
9
11
  MODE="${1:-daemon}"
12
+ LOCK_DIR="$AESOP_ROOT/state/.watchdog-lock"
13
+ LOCK_STALE_THRESHOLD=300
10
14
 
11
- # Heartbeat guard: skip if already running (within 200s)
12
- __hb=$(cat "$AESOP_ROOT/state/.watchdog-heartbeat" 2>/dev/null)
13
- __now=$(date +%s)
14
- if [ "$MODE" != "--once" ] && [ -n "$__hb" ] && [ $((__now - __hb)) -lt 200 ] 2>/dev/null; then
15
- echo "watchdog already running (heartbeat $((__now - __hb))s ago) — not starting a duplicate."
15
+ # Atomic lock acquire: mkdir is atomic (POSIX guarantees)
16
+ # Returns 0 if lock acquired, 1 if held by another process, 2 if stale lock reclaimed
17
+ acquire_lock() {
18
+ local lock_dir="$1"
19
+ local stale_threshold="$2"
20
+
21
+ # Ensure parent directory exists
22
+ mkdir -p "$(dirname "$lock_dir")" 2>/dev/null || true
23
+
24
+ # Try to create lock directory atomically (this is atomic on all POSIX systems)
25
+ if mkdir "$lock_dir" 2>/dev/null; then
26
+ # We created it; write timestamp and PID
27
+ date +%s > "$lock_dir/timestamp" 2>/dev/null
28
+ echo $$ > "$lock_dir/pid" 2>/dev/null
29
+ return 0
30
+ fi
31
+
32
+ # 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
46
+ fi
47
+ fi
48
+ fi
49
+
50
+ # Lock is held by another process
51
+ return 1
52
+ }
53
+
54
+ # Release lock: verify ownership before removing (P0 fix)
55
+ release_lock() {
56
+ local lock_dir="$1"
57
+ if [ -f "$lock_dir/pid" ]; then
58
+ local lock_pid=$(cat "$lock_dir/pid" 2>/dev/null || echo "")
59
+ if [ "$lock_pid" = "$$" ]; then
60
+ rm -rf "$lock_dir" 2>/dev/null
61
+ fi
62
+ fi
63
+ }
64
+
65
+ # Try to acquire lock (applies to both --once and daemon modes)
66
+ acquire_lock "$LOCK_DIR" "$LOCK_STALE_THRESHOLD"
67
+ lock_result=$?
68
+
69
+ if [ $lock_result -eq 1 ]; then
70
+ echo "watchdog already running — not starting a duplicate."
16
71
  exit 0
17
72
  fi
18
73
 
@@ -21,17 +76,45 @@ echo " FLEET WATCHDOG DAEMON · backup + ensure-push + scan / 150s"
21
76
  echo " logs: $AESOP_ROOT/state/FLEET-BACKUP.log · Ctrl-C to stop"
22
77
  echo "==================================================================="
23
78
  echo "[$(date '+%F %T')] === watchdog daemon (shell) STARTED ===" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
24
- trap 'echo "[$(date "+%F %T")] === watchdog daemon (shell) STOPPED ===" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"; echo "stopped."; exit 0' INT TERM
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
25
90
 
26
91
  if [ "$MODE" = "--once" ]; then
27
- bash "$AESOP_ROOT/daemons/backup-fleet.sh" 2>&1
28
- exit 0
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
99
+ fi
100
+ python "$AESOP_ROOT/tools/alert_bridge.py" --scan || true
101
+ release_lock "$LOCK_DIR"
102
+ exit $cmd_exit
29
103
  fi
30
104
 
31
105
  n=0
32
106
  while true; do
33
107
  n=$((n+1))
34
- out=$(bash "$AESOP_ROOT/daemons/backup-fleet.sh" 2>&1 | tail -2)
35
- printf '%s cycle #%d\n%s\n' "$(date '+%H:%M:%S')" "$n" "$out"
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"
113
+ 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"
117
+ fi
118
+ python "$AESOP_ROOT/tools/alert_bridge.py" --scan || true
36
119
  sleep 150
37
120
  done
package/dash/CLAUDE.md ADDED
@@ -0,0 +1,32 @@
1
+ # dash/ — Dashboard TUI
2
+
3
+ **Purpose**: Real-time TUI fleet monitoring — watchdog daemon + agent activity display.
4
+
5
+ ## Files
6
+
7
+ - **watchdog-gui.sh** — Read-only terminal UI (4s refresh, double-buffered, no flicker). Displays daemon status, backed-up repos, heartbeats, recent events. Launch in own window; never inside tool shell. CRLF-safe (no line continuations).
8
+ - **dash-extra.mjs** — Node.js agent activity detector. Scans AESOP_TRANSCRIPTS_ROOT for agent-*.jsonl files (last 12 min). TUI output by default; --json for web endpoints. Requires node on PATH (unavailable fallback if missing).
9
+
10
+ ## State contracts
11
+
12
+ **watchdog-gui.sh reads** (heartbeat staleness thresholds applied):
13
+ - `state/.watchdog-heartbeat` (epoch, threshold: 300s for watchdog)
14
+ - `state/FLEET-BACKUP.log` (append-only, tail 3 lines)
15
+ - `state/SECURITY-ALERTS.log` (counts HIGH/MED, grep filtering RESOLVED-FP)
16
+ - `state/.watchdog-repos.json` (jq-parsed, status per repo)
17
+ - `state/.heartbeats/*` (epoch, per-agent thresholds: 3600s monitor, 300s watchdog, 1800s default)
18
+
19
+ **dash-extra.mjs reads**:
20
+ - `~/.claude/projects/**/ agent-*.jsonl` (modified time, <12min filter, top 8 recent)
21
+ - `state/SECURITY-ALERTS.log` (severity classification: HIGH/MED/DRIFT)
22
+
23
+ **Refresh cadence**:
24
+ - watchdog-gui.sh: 4s loop (infinite, Ctrl-C to exit)
25
+ - dash-extra.mjs: called on-demand (no built-in loop)
26
+
27
+ ## Invariants & TUI conventions
28
+
29
+ - **Never modify fleet state** — both tools are read-only dashboards.
30
+ - **Infinite loop design** — watchdog-gui.sh runs forever; launch in dedicated terminal window, never inside tool shells.
31
+ - **CRLF-safe** — no bash line continuations; portable POSIX.
32
+ - **Node optional** — dash-extra.mjs gracefully prints "unavailable" if node missing; doesn't fail watchdog-gui.sh.