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

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 (108) hide show
  1. package/CHANGELOG.md +37 -94
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +89 -264
  4. package/aesop.config.example.json +27 -26
  5. package/bin/CLAUDE.md +39 -0
  6. package/bin/cli.js +368 -24
  7. package/daemons/CLAUDE.md +33 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +90 -10
  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/RELIABILITY.md +61 -0
  23. package/docs/RESTORE.md +397 -0
  24. package/docs/SCRIPTS-POLICY.md +97 -0
  25. package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
  26. package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
  27. package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
  28. package/docs/spikes/tiered-cognition/README.md +29 -0
  29. package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  30. package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  31. package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  32. package/hooks/CLAUDE.md +38 -0
  33. package/hooks/claude/force-model-policy.mjs +159 -0
  34. package/hooks/pre-push-policy.sh +753 -0
  35. package/monitor/.signal-state.json +3 -0
  36. package/monitor/ACTIONS.log +1 -0
  37. package/monitor/BRIEF.md +24 -0
  38. package/monitor/CHARTER.md +29 -8
  39. package/monitor/CLAUDE.md +40 -0
  40. package/monitor/SIGNALS.json +54 -0
  41. package/monitor/collect-signals.mjs +529 -62
  42. package/package.json +12 -3
  43. package/skills/CLAUDE.md +3 -0
  44. package/skills/power/SKILL.md +161 -0
  45. package/tools/CLAUDE.md +191 -0
  46. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  52. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  53. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  54. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  55. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  56. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  57. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  58. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  59. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  60. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  61. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  62. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  63. package/tools/agent-forensics.sh +186 -0
  64. package/tools/buildlog.py +182 -0
  65. package/tools/ci_merge_wait.py +204 -0
  66. package/tools/ensure_state.py +94 -0
  67. package/tools/eod_sweep.py +239 -0
  68. package/tools/fleet_ledger.py +272 -0
  69. package/tools/heartbeat.py +131 -0
  70. package/tools/inbox_drain.py +249 -0
  71. package/tools/launch_tui.py +51 -3
  72. package/tools/lock.mjs +211 -0
  73. package/tools/metrics_gate.py +205 -0
  74. package/tools/orchestrator_status.py +99 -0
  75. package/tools/power_selftest.py +386 -0
  76. package/tools/prepublish_scan.py +84 -0
  77. package/tools/proposals.mjs +248 -0
  78. package/tools/reconstitute.sh +467 -0
  79. package/tools/rotate_logs.py +228 -0
  80. package/tools/scanner_selftest.py +303 -0
  81. package/tools/secret_scan.py +131 -31
  82. package/tools/stall_check.py +172 -0
  83. package/tools/verify_dash.py +694 -0
  84. package/tools/verify_submit_encoding.py +194 -0
  85. package/ui/CLAUDE.md +109 -0
  86. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  87. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  88. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  89. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  90. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  91. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  92. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  93. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  94. package/ui/agents.py +179 -0
  95. package/ui/api/__init__.py +84 -0
  96. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  97. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  98. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  99. package/ui/api/submit.py +58 -0
  100. package/ui/api/tracker.py +116 -0
  101. package/ui/collectors.py +586 -0
  102. package/ui/config.py +114 -0
  103. package/ui/csrf.py +140 -0
  104. package/ui/handler.py +426 -0
  105. package/ui/render.py +29 -0
  106. package/ui/serve.py +68 -796
  107. package/ui/sse.py +168 -0
  108. package/ui/templates/dashboard.html +1202 -0
@@ -4,15 +4,69 @@
4
4
  # Usage: run-watchdog.sh [--once]
5
5
  #
6
6
  # Configuration: export AESOP_ROOT=/path/to/aesop before running, or edit default below.
7
+ # Testing: export AESOP_WATCHDOG_CYCLE_CMD to override backup-fleet.sh invocation
7
8
 
8
9
  AESOP_ROOT="${AESOP_ROOT:-.}"
9
10
  MODE="${1:-daemon}"
11
+ LOCK_DIR="$AESOP_ROOT/state/.watchdog-lock"
12
+ LOCK_STALE_THRESHOLD=300
10
13
 
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."
14
+ # Atomic lock acquire: mkdir is atomic (POSIX guarantees)
15
+ # Returns 0 if lock acquired, 1 if held by another process, 2 if stale lock reclaimed
16
+ acquire_lock() {
17
+ local lock_dir="$1"
18
+ local stale_threshold="$2"
19
+
20
+ # Ensure parent directory exists
21
+ mkdir -p "$(dirname "$lock_dir")" 2>/dev/null || true
22
+
23
+ # Try to create lock directory atomically (this is atomic on all POSIX systems)
24
+ if mkdir "$lock_dir" 2>/dev/null; then
25
+ # We created it; write timestamp and PID
26
+ date +%s > "$lock_dir/timestamp" 2>/dev/null
27
+ echo $$ > "$lock_dir/pid" 2>/dev/null
28
+ return 0
29
+ fi
30
+
31
+ # Lock directory already exists; check if it's stale
32
+ if [ -d "$lock_dir" ] && [ -f "$lock_dir/timestamp" ]; then
33
+ local lock_mtime=$(cat "$lock_dir/timestamp" 2>/dev/null || echo 0)
34
+ local now=$(date +%s)
35
+ local lock_age=$((now - lock_mtime))
36
+
37
+ if [ "$lock_age" -gt "$stale_threshold" ]; then
38
+ # Lock is stale; try to reclaim it
39
+ rm -rf "$lock_dir" 2>/dev/null || true
40
+ if mkdir "$lock_dir" 2>/dev/null; then
41
+ date +%s > "$lock_dir/timestamp" 2>/dev/null
42
+ echo $$ > "$lock_dir/pid" 2>/dev/null
43
+ echo "watchdog lock was stale (${lock_age}s) — reclaimed." >&2
44
+ return 2
45
+ fi
46
+ fi
47
+ fi
48
+
49
+ # Lock is held by another process
50
+ return 1
51
+ }
52
+
53
+ # Release lock: verify ownership before removing (P0 fix)
54
+ release_lock() {
55
+ local lock_dir="$1"
56
+ if [ -f "$lock_dir/pid" ]; then
57
+ local lock_pid=$(cat "$lock_dir/pid" 2>/dev/null || echo "")
58
+ if [ "$lock_pid" = "$$" ]; then
59
+ rm -rf "$lock_dir" 2>/dev/null
60
+ fi
61
+ fi
62
+ }
63
+
64
+ # Try to acquire lock (applies to both --once and daemon modes)
65
+ acquire_lock "$LOCK_DIR" "$LOCK_STALE_THRESHOLD"
66
+ lock_result=$?
67
+
68
+ if [ $lock_result -eq 1 ]; then
69
+ echo "watchdog already running — not starting a duplicate."
16
70
  exit 0
17
71
  fi
18
72
 
@@ -21,17 +75,43 @@ echo " FLEET WATCHDOG DAEMON · backup + ensure-push + scan / 150s"
21
75
  echo " logs: $AESOP_ROOT/state/FLEET-BACKUP.log · Ctrl-C to stop"
22
76
  echo "==================================================================="
23
77
  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
78
+ 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
79
+
80
+ # Allow override of backup cycle command (for testing)
81
+ # Use array to safely handle paths with spaces (P1 fix)
82
+ if [ -n "$AESOP_WATCHDOG_CYCLE_CMD" ]; then
83
+ # Override: run as-is through bash -c
84
+ CYCLE_CMD_ARRAY=("bash" "-c" "$AESOP_WATCHDOG_CYCLE_CMD")
85
+ else
86
+ # Default: array form for proper quoting
87
+ CYCLE_CMD_ARRAY=("bash" "$AESOP_ROOT/daemons/backup-fleet.sh")
88
+ fi
25
89
 
26
90
  if [ "$MODE" = "--once" ]; then
27
- bash "$AESOP_ROOT/daemons/backup-fleet.sh" 2>&1
28
- exit 0
91
+ full_out=$("${CYCLE_CMD_ARRAY[@]}" 2>&1)
92
+ cmd_exit=$?
93
+ echo "$full_out"
94
+ if [ $cmd_exit -ne 0 ]; then
95
+ err_msg="[$(date '+%F %T')] ERROR: cycle #1 failed with exit code $cmd_exit"
96
+ echo "$err_msg" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
97
+ echo "[ERROR: exit $cmd_exit]" >&2
98
+ fi
99
+ release_lock "$LOCK_DIR"
100
+ exit $cmd_exit
29
101
  fi
30
102
 
31
103
  n=0
32
104
  while true; do
33
105
  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"
106
+ full_out=$("${CYCLE_CMD_ARRAY[@]}" 2>&1)
107
+ cmd_exit=$?
108
+ if [ $cmd_exit -eq 0 ]; then
109
+ out=$(echo "$full_out" | tail -2)
110
+ printf '%s cycle #%d\n%s\n' "$(date '+%H:%M:%S')" "$n" "$out"
111
+ else
112
+ echo "[$(date '+%F %T')] ERROR: cycle #$n failed with exit code $cmd_exit" >> "$AESOP_ROOT/state/FLEET-BACKUP.log"
113
+ out=$(echo "$full_out" | tail -2)
114
+ printf '%s cycle #%d [ERROR: exit %d]\n%s\n' "$(date '+%H:%M:%S')" "$n" "$cmd_exit" "$out"
115
+ fi
36
116
  sleep 150
37
117
  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.
@@ -1,18 +1,62 @@
1
1
  // Detect and render running Claude agents from transcripts.
2
2
  // Usage: node dash-extra.mjs [--json]
3
- // --json: output JSON array of agents (for web dashboard /data endpoint)
3
+ // --json: output JSON array of agents with rich metadata (for web dashboard)
4
4
  // (default): render TUI text output (for terminal dashboard)
5
+ //
6
+ // Enhanced JSONL parsing: extracts dispatch prompt, token counts, runtime, task label
7
+ // Robust: tolerates malformed lines, caps read size on large files
5
8
 
6
9
  import fs from 'node:fs';
7
10
  import path from 'node:path';
11
+ import os from 'node:os';
12
+
13
+ // Configuration: env var > config file > built-in default
14
+ // Helper: load aesop.config.json if it exists
15
+ function loadConfigFile(aesopRoot) {
16
+ try {
17
+ const configPath = path.join(aesopRoot, 'aesop.config.json');
18
+ if (fs.existsSync(configPath)) {
19
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
20
+ }
21
+ } catch {
22
+ // Parse error or file doesn't exist; ignore
23
+ }
24
+ return {};
25
+ }
26
+
27
+ // Helper: expand ~ and environment variables in paths for portability
28
+ function expandPath(pathStr) {
29
+ if (!pathStr) return pathStr;
30
+ // Expand ~ to home directory
31
+ if (pathStr.startsWith('~')) {
32
+ return path.join(os.homedir(), pathStr.slice(1));
33
+ }
34
+ // Expand environment variables like $VAR or %VAR%
35
+ return pathStr.replace(/\$\{?([A-Z_]+)\}?/gi, (match, varName) => {
36
+ return process.env[varName] || match;
37
+ });
38
+ }
8
39
 
9
- // Configuration from environment
10
40
  const AESOP_ROOT = process.env.AESOP_ROOT || path.join(process.env.HOME || '.', 'aesop');
41
+ const config = loadConfigFile(AESOP_ROOT);
42
+
11
43
  const TRANSCRIPTS_ROOT = path.resolve(
12
- process.env.AESOP_TRANSCRIPTS_ROOT || path.join(process.env.HOME || '.', '.claude', 'projects')
44
+ expandPath(
45
+ process.env.AESOP_TRANSCRIPTS_ROOT ||
46
+ config.transcripts_root ||
47
+ path.join(process.env.HOME || '.', '.claude', 'projects')
48
+ )
13
49
  );
14
- const SCAN_DIR = path.join(AESOP_ROOT, 'scan');
15
- const ALERTS_LOG = path.join(SCAN_DIR, 'SECURITY-ALERTS.log');
50
+
51
+ // Resolve state_root: env var > config > default
52
+ const STATE_ROOT = path.resolve(
53
+ expandPath(
54
+ process.env.AESOP_STATE_ROOT ||
55
+ config.state_root ||
56
+ path.join(AESOP_ROOT, 'state')
57
+ )
58
+ );
59
+ const ALERTS_LOG = path.join(STATE_ROOT, 'SECURITY-ALERTS.log');
16
60
 
17
61
  // ANSI colors for TUI output
18
62
  const c = {
@@ -29,6 +73,110 @@ const c = {
29
73
  const now = Date.now();
30
74
  const out = [];
31
75
 
76
+ // Activity window: only include files modified within the last 12 minutes
77
+ const ACTIVITY_WINDOW = 12 * 60 * 1000;
78
+
79
+ // Depth limit to match monitor's equivalent (prevent unbounded recursion on growing tree)
80
+ const MAX_DEPTH = 6;
81
+
82
+ // Parse JSONL agent transcript and extract rich metadata
83
+ function parseAgentJsonl(filePath) {
84
+ const metadata = {
85
+ promptFull: '',
86
+ taskLabel: '',
87
+ tokensUsed: 0,
88
+ startedAt: null,
89
+ lastActivity: null,
90
+ runtimeSeconds: 0,
91
+ project: ''
92
+ };
93
+
94
+ try {
95
+ // Derive project from path: transcripts_root/PROJECT/session/...
96
+ const relPath = path.relative(TRANSCRIPTS_ROOT, filePath);
97
+ const parts = relPath.split(path.sep);
98
+ if (parts.length > 0) {
99
+ metadata.project = parts[0];
100
+ }
101
+
102
+ const content = fs.readFileSync(filePath, 'utf8');
103
+ const lines = content.split('\n');
104
+
105
+ // Cap reading: first 50 + last 50 lines to avoid expensive parsing on huge files
106
+ const linesToRead = [];
107
+ if (lines.length <= 100) {
108
+ linesToRead.push(...lines);
109
+ } else {
110
+ linesToRead.push(...lines.slice(0, 50));
111
+ linesToRead.push(...lines.slice(-50));
112
+ }
113
+
114
+ let firstTimestamp = null;
115
+ let lastTimestamp = null;
116
+
117
+ for (const line of linesToRead) {
118
+ if (!line.trim()) continue;
119
+
120
+ try {
121
+ const obj = JSON.parse(line);
122
+
123
+ // Extract dispatch prompt from first user message
124
+ if (obj.type === 'user' && obj.message && !metadata.promptFull) {
125
+ const content = obj.message.content;
126
+ if (typeof content === 'string') {
127
+ metadata.promptFull = content;
128
+ // Extract task label: first line of prompt, capped at 80 chars
129
+ const firstLine = content.split('\n')[0];
130
+ metadata.taskLabel = firstLine.substring(0, 80);
131
+ }
132
+ }
133
+
134
+ // Track timestamps
135
+ if (obj.timestamp) {
136
+ const ts = new Date(obj.timestamp).getTime();
137
+ if (!firstTimestamp) {
138
+ firstTimestamp = ts;
139
+ metadata.startedAt = obj.timestamp;
140
+ }
141
+ lastTimestamp = ts;
142
+ metadata.lastActivity = obj.timestamp;
143
+ }
144
+
145
+ // (token accumulation is a separate FULL-file pass below — sampling
146
+ // only 100 lines under-counts tokens badly on long transcripts)
147
+ } catch {
148
+ // Silently skip malformed JSON lines
149
+ continue;
150
+ }
151
+ }
152
+
153
+ // Token total: scan EVERY line (usage records are spread throughout the
154
+ // transcript, not just the sampled head/tail). Cheap substring pre-filter
155
+ // keeps this fast on huge files by JSON-parsing only candidate lines.
156
+ for (const line of lines) {
157
+ if (!line.includes('"usage"')) continue;
158
+ try {
159
+ const obj = JSON.parse(line);
160
+ if (obj.type === 'assistant' && obj.usage) {
161
+ const { input_tokens = 0, output_tokens = 0 } = obj.usage;
162
+ metadata.tokensUsed += input_tokens + output_tokens;
163
+ }
164
+ } catch {
165
+ continue;
166
+ }
167
+ }
168
+
169
+ // Calculate runtime in seconds
170
+ if (firstTimestamp && lastTimestamp) {
171
+ metadata.runtimeSeconds = Math.floor((lastTimestamp - firstTimestamp) / 1000);
172
+ }
173
+ } catch {
174
+ // If file read fails, return empty metadata
175
+ }
176
+
177
+ return metadata;
178
+ }
179
+
32
180
  // Read alerts log if present
33
181
  let slog = [];
34
182
  try {
@@ -37,14 +185,22 @@ try {
37
185
  }
38
186
  } catch {}
39
187
 
40
- // Recursively walk directory tree to find agent-*.jsonl files
41
- function walk(dir, accumulator) {
188
+ // Recursively walk directory tree to find agent-*.jsonl files.
189
+ // Respects depth limit. Activity window filtering happens per-file, not per-directory,
190
+ // to avoid hiding active agents in dirs whose mtime was frozen at creation.
191
+ function walk(dir, accumulator, depth = 0) {
192
+ // Stop recursion if depth exceeds limit
193
+ if (depth > MAX_DEPTH) return;
194
+
42
195
  try {
43
196
  const entries = fs.readdirSync(dir, { withFileTypes: true });
44
197
  for (const entry of entries) {
45
198
  const fullPath = path.join(dir, entry.name);
46
199
  if (entry.isDirectory()) {
47
- walk(fullPath, accumulator);
200
+ // Always descend into subdirectories (respecting depth limit only)
201
+ // Do NOT prune based on directory mtime, as directories don't update
202
+ // when files inside are appended to.
203
+ walk(fullPath, accumulator, depth + 1);
48
204
  } else if (/^agent-.*\.jsonl$/.test(entry.name)) {
49
205
  accumulator.push(fullPath);
50
206
  }
@@ -68,8 +224,13 @@ files = files
68
224
  return { f, mtime };
69
225
  })
70
226
  .filter(x => now - x.mtime < 12 * 60 * 1000)
71
- .sort((a, b) => b.mtime - a.mtime)
72
- .slice(0, 8);
227
+ .sort((a, b) => b.mtime - a.mtime);
228
+
229
+ // True active total. The 8 below is a TUI terminal-space limit ONLY — not the
230
+ // real count. The web (--json) emits ALL active agents; the TUI shows 8 + "N more".
231
+ const totalActive = files.length;
232
+ const runningCount = files.filter(x => now - x.mtime < 120 * 1000).length;
233
+ const TUI_ROW_CAP = 8;
73
234
 
74
235
  // Extract description/hint from first ~60KB of agent transcript
75
236
  function label(filePath) {
@@ -93,14 +254,13 @@ function label(filePath) {
93
254
  // TUI output: render heading
94
255
  out.push(`${c.B} FLEET AGENTS${c.X} ${c.D}(green=running · severity-colored if flagged)${c.X}`);
95
256
 
96
- if (files.length === 0) {
257
+ if (totalActive === 0) {
97
258
  out.push(` ${c.D}(no active fleet agents in last 12 min)${c.X}`);
98
259
  }
99
260
 
100
- let runningCount = 0;
101
-
102
- // Render each agent
103
- for (const { f, mtime } of files) {
261
+ // Render each agent (TUI caps rows to TUI_ROW_CAP for terminal space; the counts
262
+ // above are the true totals across all active agents)
263
+ for (const { f, mtime } of files.slice(0, TUI_ROW_CAP)) {
104
264
  const basename = path.basename(f);
105
265
  const ageSeconds = Math.round((now - mtime) / 1000);
106
266
 
@@ -110,8 +270,6 @@ for (const { f, mtime } of files) {
110
270
  let statusColor = ageSeconds < 120 ? c.G : c.D;
111
271
  let statusText = ageSeconds < 120 ? 'running' : 'idle';
112
272
 
113
- if (ageSeconds < 120) runningCount++;
114
-
115
273
  // Recolor based on alert severity
116
274
  if (alertsForAgent.some(l => l.includes('SUSPICIOUS'))) {
117
275
  statusColor = c.R;
@@ -140,8 +298,12 @@ for (const { f, mtime } of files) {
140
298
  );
141
299
  }
142
300
 
143
- if (files.length > 0) {
144
- out.push(` ${c.D}${runningCount} running, ${files.length - runningCount} idle (last 12 min)${c.X}`);
301
+ if (totalActive > TUI_ROW_CAP) {
302
+ out.push(` ${c.D} +${totalActive - TUI_ROW_CAP} more active (showing ${TUI_ROW_CAP} of ${totalActive})${c.X}`);
303
+ }
304
+
305
+ if (totalActive > 0) {
306
+ out.push(` ${c.D}${runningCount} running, ${totalActive - runningCount} idle (last 12 min)${c.X}`);
145
307
  }
146
308
 
147
309
  // Output
@@ -170,11 +332,22 @@ if (process.argv.includes('--json')) {
170
332
  .replace(/\.jsonl$/, '')
171
333
  .slice(0, 13);
172
334
 
335
+ // Parse JSONL for rich metadata
336
+ const metadata = parseAgentJsonl(f);
337
+
173
338
  agents.push({
174
339
  id: agentId,
175
- age_s: ageSeconds,
340
+ project: metadata.project,
176
341
  status: status,
177
- hint: label(f).slice(0, 60)
342
+ age_s: ageSeconds,
343
+ hint: label(f).slice(0, 60),
344
+ // New enriched fields
345
+ startedAt: metadata.startedAt,
346
+ lastActivity: metadata.lastActivity,
347
+ runtimeSeconds: metadata.runtimeSeconds,
348
+ tokensUsed: metadata.tokensUsed,
349
+ taskLabel: metadata.taskLabel,
350
+ promptFull: metadata.promptFull
178
351
  });
179
352
  }
180
353
  process.stdout.write(JSON.stringify(agents) + '\n');
@@ -21,6 +21,7 @@ SLOG="$AESOP_ROOT/state/SECURITY-ALERTS.log"
21
21
  HB="$AESOP_ROOT/state/.watchdog-heartbeat"
22
22
  REPOS_FILE="$AESOP_ROOT/state/.watchdog-repos.json"
23
23
  HB_DIR="$AESOP_ROOT/state/.heartbeats"
24
+ CONFIG_FILE="$AESOP_ROOT/aesop.config.json"
24
25
 
25
26
  SPINNER=0
26
27
  FIRST_FRAME=1
@@ -29,15 +30,31 @@ FIRST_FRAME=1
29
30
  trap 'printf "\033[?25h"; exit 0' INT TERM
30
31
  printf '\033[?25l'
31
32
 
33
+ # Load heartbeat thresholds from config; fallback to built-in defaults
34
+ load_hb_thresholds() {
35
+ if [ -f "$CONFIG_FILE" ] && command -v jq >/dev/null 2>&1; then
36
+ HB_THRESHOLD_MONITOR=$(jq -r '.monitor.heartbeat_thresholds.monitor // 3600' "$CONFIG_FILE" 2>/dev/null || echo 3600)
37
+ HB_THRESHOLD_WATCHDOG=$(jq -r '.monitor.heartbeat_thresholds.watchdog // 300' "$CONFIG_FILE" 2>/dev/null || echo 300)
38
+ HB_THRESHOLD_DEFAULT=$(jq -r '.monitor.heartbeat_thresholds.default // 1800' "$CONFIG_FILE" 2>/dev/null || echo 1800)
39
+ else
40
+ HB_THRESHOLD_MONITOR=3600
41
+ HB_THRESHOLD_WATCHDOG=300
42
+ HB_THRESHOLD_DEFAULT=1800
43
+ fi
44
+ }
45
+
32
46
  get_hb_threshold() {
33
47
  local name="$1"
34
48
  case "$name" in
35
- *monitor*) echo 3600;;
36
- *watchdog*) echo 300;;
37
- *) echo 300;;
49
+ *monitor*) echo "$HB_THRESHOLD_MONITOR";;
50
+ *watchdog*) echo "$HB_THRESHOLD_WATCHDOG";;
51
+ *) echo "$HB_THRESHOLD_DEFAULT";;
38
52
  esac
39
53
  }
40
54
 
55
+ # Load thresholds at startup
56
+ load_hb_thresholds
57
+
41
58
  render_frame() {
42
59
  local FRAME=""
43
60
  FRAME="${FRAME}${B}${C}== FLEET WATCHDOG${X}\n"
@@ -122,8 +139,8 @@ while true; do
122
139
  WD="${R}STALE${X}"
123
140
  fi
124
141
 
125
- HI=$(grep -v '^RESOLVED-FP' "$SLOG" 2>/dev/null | grep -c ' HIGH '); HI=${HI:-0}
126
- ME=$(grep -v '^RESOLVED-FP' "$SLOG" 2>/dev/null | grep -c ' MED '); ME=${ME:-0}
142
+ set -- $(awk '/^RESOLVED-FP/ { next } / HIGH / { hi++ } / MED / { me++ } END { print hi+0 " " me+0 }' "$SLOG" 2>/dev/null || echo '0 0')
143
+ HI="$1"; ME="$2"
127
144
 
128
145
  if [ "$FIRST_FRAME" -eq 1 ]; then
129
146
  printf '\033[2J'
@@ -133,7 +150,7 @@ while true; do
133
150
  fi
134
151
 
135
152
  render_frame | while IFS= read -r line; do
136
- printf '%b\033[K\n' "$line"
153
+ printf '%s\033[K\n' "$line"
137
154
  done
138
155
  printf '\033[J'
139
156
 
@@ -0,0 +1,86 @@
1
+ # Behavioral PR Review Checklist
2
+
3
+ For pull requests that modify operational rules, agent behavior, monitoring, or orchestration policy, follow this checklist **before merging**.
4
+
5
+ ## 1. Rule & Cardinal Alignment
6
+
7
+ - [ ] Rule named explicitly (e.g., "Rule 3: Reliability core" or "Cardinal Rule 1: Dispatch model")
8
+ - [ ] PR title and description clearly state which rule(s) are affected
9
+ - [ ] Change aligns with (or explicitly updates) `docs/CARDINAL-RULES.md`
10
+ - [ ] No contradictions with other cardinal rules or guardrails
11
+
12
+ ## 2. Blast Radius & Impact
13
+
14
+ - [ ] Impact scope documented (e.g., "affects all new monitor cycles", "affects heartbeat thresholds only", "no impact on running agents")
15
+ - [ ] Backward compatibility assessed (breaking changes require migration plan)
16
+ - [ ] Dependent systems identified (e.g., if monitor behavior changes, does ACTIONS.log parsing break?)
17
+ - [ ] Risk of silent failures assessed (e.g., does fallback-to-default mask issues?)
18
+
19
+ ## 3. Verification & Testing
20
+
21
+ - [ ] Reproducible test steps provided (not "tested locally" — state what was tested)
22
+ - [ ] Test output attached or described (e.g., "ran `node monitor/collect-signals.mjs` twice, no duplicate PROPOSALS.md entries")
23
+ - [ ] Idempotency verified if applicable (safe to run multiple times in same cycle?)
24
+ - [ ] Edge cases covered (what if config missing? what if file unreadable?)
25
+
26
+ ## 4. Rollback & Recovery
27
+
28
+ - [ ] Rollback plan documented (e.g., "revert commit + clear `.signal-state.json` + restart monitor")
29
+ - [ ] Rollback tested if possible (e.g., apply change, verify it works, revert, verify rollback works)
30
+ - [ ] No irreversible side effects (don't delete files without append-only logging first)
31
+
32
+ ## 5. Single-Writer & Proposal Provenance
33
+
34
+ - [ ] If PROPOSALS.md or ACTIONS.log touched: confirm monitor still writes atomically (append-only)
35
+ - [ ] If new proposal emission logic added: confirm idempotency (no duplicate proposals from re-runs)
36
+ - [ ] If proposal came from monitor-collected signal: CHARTER.md action tier justified (AUTO vs PROPOSE)
37
+ - [ ] If proposal came from human: documented in PROPOSALS.md (what human, when, why)
38
+
39
+ ## 6. Documentation & Handoff
40
+
41
+ - [ ] Related doc updated (CHARTER.md, CARDINAL-RULES.md, GOVERNANCE.md, etc.)
42
+ - [ ] Code comments added for non-obvious rule enforcement logic
43
+ - [ ] Affected stakeholders notified (e.g., if monitor behavior changes, note in CONTRIBUTING.md)
44
+
45
+ ---
46
+
47
+ ## Worked Example: Cost-cap-per-repo Change
48
+
49
+ **PR Title:** `feat(dispatch): Enforce Haiku cost cap per-repo instead of global`
50
+
51
+ **Rule affected:** Cardinal Rule 1 (dispatch model), Rule 6 (commit discipline)
52
+
53
+ **Blast radius:** All new agent dispatches via claude-code-cli; existing running agents unaffected
54
+
55
+ **Verification:**
56
+ - Ran dispatch-test-harness.sh with 3 mock repos, 5 Haiku spawns per repo
57
+ - Confirmed ledger recorded per-repo cost separately
58
+ - Rolled back, confirmed ledger reverted to single global entry
59
+ - No test script in package.json; test via integration harness
60
+
61
+ **Rollback:** Revert commit, clear `~/.claude/projects/*/FLEET-LEDGER.md`, restart orchestrator
62
+
63
+ **Documentation:** Updated DISPATCH-MODEL.md section "Cost tracking" with new per-repo tracking format
64
+
65
+ **Result:** ✓ Merged after review
66
+
67
+ ---
68
+
69
+ ## When to Request Changes (🔴 Critical)
70
+
71
+ - Missing verification or test steps
72
+ - Rollback plan untested or implausible
73
+ - Proposal came from monitor but action tier (AUTO/PROPOSE) unjustified
74
+ - Change introduces hardcoded paths, secrets, or usernames
75
+ - Backward-incompatible change with no migration documented
76
+ - Cardinal rule contradiction or guardrail violation
77
+
78
+ ## When to Suggest (💡 Consider)
79
+
80
+ - Minor documentation clarification
81
+ - Additional edge case to test (not required, but good to consider)
82
+ - Related code that could benefit from similar fix (out of scope for this PR, open separate issue)
83
+
84
+ ## Proposal lifecycle
85
+
86
+ Use `node tools/proposals.mjs list` to review pending proposals in monitor/PROPOSALS.md. To approve a proposal, run `node tools/proposals.mjs accept <signal-key>` (moves block to PROPOSALS-LOG.md under `## ACCEPTED <timestamp>`); to decline, run `reject <signal-key>` instead. Both commands are idempotent: re-running on an already-moved proposal is a no-op.