@matt82198/aesop 0.1.0-beta.1
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.
- package/CHANGELOG.md +126 -0
- package/LICENSE +21 -0
- package/README.md +333 -0
- package/aesop.config.example.json +47 -0
- package/bin/cli.js +112 -0
- package/daemons/backup-fleet.sh +173 -0
- package/daemons/run-watchdog.sh +37 -0
- package/dash/dash-extra.mjs +184 -0
- package/dash/watchdog-gui.sh +141 -0
- package/docs/CARDINAL-RULES.md +146 -0
- package/docs/DISPATCH-MODEL.md +180 -0
- package/docs/PUBLISHING.md +150 -0
- package/docs/av-resilience.md +246 -0
- package/monitor/CHARTER.md +66 -0
- package/monitor/collect-signals.mjs +137 -0
- package/package.json +50 -0
- package/tools/launch_tui.py +245 -0
- package/tools/secret_scan.py +363 -0
- package/ui/README.md +141 -0
- package/ui/serve.py +806 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -uo pipefail
|
|
3
|
+
# Backup fleet repos: stash uncommitted work, push unpushed commits to backup branches.
|
|
4
|
+
# Runs every 150s from run-watchdog.sh. Abort on secret-scan failures.
|
|
5
|
+
# Improvements: dot-directory discovery, path dedup, tracked-files-only secret scanning.
|
|
6
|
+
|
|
7
|
+
AESOP_ROOT="${AESOP_ROOT:-.}"
|
|
8
|
+
HEARTBEAT="$AESOP_ROOT/state/.watchdog-heartbeat"
|
|
9
|
+
LOG="$AESOP_ROOT/state/FLEET-BACKUP.log"
|
|
10
|
+
REPOS_STATUS="$AESOP_ROOT/state/.watchdog-repos.json"
|
|
11
|
+
|
|
12
|
+
date +%s > "$HEARTBEAT" 2>/dev/null
|
|
13
|
+
ts() { date '+%Y-%m-%d %H:%M:%S'; }
|
|
14
|
+
log() { echo "[$(ts)] $*" | tee -a "$LOG"; }
|
|
15
|
+
|
|
16
|
+
is_touched() {
|
|
17
|
+
local repo="$1"
|
|
18
|
+
[ ! -d "$repo/.git" ] && return 1
|
|
19
|
+
(
|
|
20
|
+
cd "$repo" || return 1
|
|
21
|
+
[ -n "$(git status --porcelain 2>/dev/null)" ] && return 0
|
|
22
|
+
[ -n "$(git log @{u}.. --oneline 2>/dev/null)" ] && return 0
|
|
23
|
+
return 1
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get_tracked_modifications() {
|
|
28
|
+
local repo="$1"
|
|
29
|
+
(
|
|
30
|
+
cd "$repo" || return 1
|
|
31
|
+
git diff --name-only HEAD 2>/dev/null
|
|
32
|
+
git ls-files -m 2>/dev/null
|
|
33
|
+
) | sort -u
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
scan_tracked_files() {
|
|
37
|
+
local repo="$1"
|
|
38
|
+
local tracked_files
|
|
39
|
+
local file_paths=""
|
|
40
|
+
tracked_files=$(get_tracked_modifications "$repo")
|
|
41
|
+
|
|
42
|
+
if [ -z "$tracked_files" ]; then
|
|
43
|
+
return 0
|
|
44
|
+
fi
|
|
45
|
+
|
|
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
|
|
52
|
+
EOF
|
|
53
|
+
|
|
54
|
+
if [ -z "$file_paths" ]; then
|
|
55
|
+
return 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
if [ -f "$AESOP_ROOT/tools/secret_scan.py" ]; then
|
|
59
|
+
python "$AESOP_ROOT/tools/secret_scan.py" $file_paths >/dev/null 2>&1
|
|
60
|
+
fi
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get_default_branch() {
|
|
64
|
+
local repo="$1"
|
|
65
|
+
(cd "$repo" && git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's/refs\/remotes\/origin\///' || echo "master")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process_repo() {
|
|
69
|
+
local repo="$1"
|
|
70
|
+
local name=$(basename "$repo")
|
|
71
|
+
local default=$(get_default_branch "$repo")
|
|
72
|
+
|
|
73
|
+
(
|
|
74
|
+
cd "$repo" || exit 0
|
|
75
|
+
git fetch -q origin 2>/dev/null
|
|
76
|
+
local uncommitted=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
77
|
+
|
|
78
|
+
if [ "$uncommitted" -gt 0 ]; then
|
|
79
|
+
TMPIDX=$(mktemp)
|
|
80
|
+
GIT_INDEX_FILE="$TMPIDX" git read-tree HEAD 2>/dev/null
|
|
81
|
+
GIT_INDEX_FILE="$TMPIDX" git add -A 2>/dev/null
|
|
82
|
+
TREE=$(GIT_INDEX_FILE="$TMPIDX" git write-tree 2>/dev/null)
|
|
83
|
+
rm -f "$TMPIDX"
|
|
84
|
+
LOCAL=$(git rev-parse HEAD 2>/dev/null)
|
|
85
|
+
HEADTREE=$(git rev-parse 'HEAD^{tree}' 2>/dev/null)
|
|
86
|
+
if [ -n "$TREE" ] && [ "$TREE" != "$HEADTREE" ]; then
|
|
87
|
+
COMMIT=$(git commit-tree "$TREE" -p "$LOCAL" -m "wip $(ts) — $uncommitted files" 2>/dev/null)
|
|
88
|
+
WIPREF="backup/wip-$(date +%Y%m%d)"
|
|
89
|
+
if scan_tracked_files "$repo"; then
|
|
90
|
+
if git push -qf origin "$COMMIT:refs/heads/$WIPREF" 2>/dev/null; then
|
|
91
|
+
printf 'SNAPSHOTTED|%s\n' "$name"
|
|
92
|
+
exit 0
|
|
93
|
+
fi
|
|
94
|
+
else
|
|
95
|
+
printf 'BLOCKED|%s\n' "$name"
|
|
96
|
+
exit 0
|
|
97
|
+
fi
|
|
98
|
+
fi
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
local unpushed=$(git log @{u}.. --oneline 2>/dev/null | wc -l | tr -d ' ')
|
|
102
|
+
if [ "$unpushed" -gt 0 ]; then
|
|
103
|
+
local branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
104
|
+
[ "$branch" = "HEAD" ] && branch="$default"
|
|
105
|
+
|
|
106
|
+
if [ "$branch" = "$default" ]; then
|
|
107
|
+
WIPREF="backup/master-wip-$(date +%Y%m%d)"
|
|
108
|
+
if scan_tracked_files "$repo"; then
|
|
109
|
+
if git push -qf origin "HEAD:refs/heads/$WIPREF" 2>/dev/null; then
|
|
110
|
+
printf 'SNAPSHOTTED|%s\n' "$name"
|
|
111
|
+
exit 0
|
|
112
|
+
fi
|
|
113
|
+
else
|
|
114
|
+
printf 'BLOCKED|%s\n' "$name"
|
|
115
|
+
exit 0
|
|
116
|
+
fi
|
|
117
|
+
else
|
|
118
|
+
if scan_tracked_files "$repo"; then
|
|
119
|
+
if git push -q origin "$branch" 2>/dev/null; then
|
|
120
|
+
printf 'PUSHED|%s\n' "$name"
|
|
121
|
+
exit 0
|
|
122
|
+
fi
|
|
123
|
+
else
|
|
124
|
+
printf 'BLOCKED|%s\n' "$name"
|
|
125
|
+
exit 0
|
|
126
|
+
fi
|
|
127
|
+
fi
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
printf 'CLEAN|%s\n' "$name"
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
log "=== cycle start ==="
|
|
135
|
+
temp_json=$(mktemp)
|
|
136
|
+
echo "[" > "$temp_json"
|
|
137
|
+
first=1
|
|
138
|
+
processed_paths=""
|
|
139
|
+
|
|
140
|
+
# Discover all git repos: scan home dot-directories, root directories, dev/ subdirectory
|
|
141
|
+
# This pattern includes ~/.* (dot-dirs like .claude), ~/* (home root), ~/dev/* (dev subtree)
|
|
142
|
+
for dir in ~/.* ~/* ~/dev/*; do
|
|
143
|
+
base=$(basename "$dir")
|
|
144
|
+
[ "$base" = "." ] || [ "$base" = ".." ] && continue
|
|
145
|
+
[ ! -d "$dir/.git" ] && continue
|
|
146
|
+
|
|
147
|
+
# Normalize path to detect duplicates (e.g., .claude vs .claude/)
|
|
148
|
+
real_dir=$(cd "$dir" && pwd 2>/dev/null)
|
|
149
|
+
if [ -z "$real_dir" ]; then continue; fi
|
|
150
|
+
if echo "$processed_paths" | grep -Fq "$real_dir"; then
|
|
151
|
+
continue
|
|
152
|
+
fi
|
|
153
|
+
processed_paths="$processed_paths
|
|
154
|
+
$real_dir"
|
|
155
|
+
|
|
156
|
+
if is_touched "$dir"; then
|
|
157
|
+
result=$(process_repo "$dir")
|
|
158
|
+
IFS="|" read -r state name <<< "$result"
|
|
159
|
+
[ -z "$state" ] && continue
|
|
160
|
+
if [ "$first" = 1 ]; then
|
|
161
|
+
first=0
|
|
162
|
+
else
|
|
163
|
+
echo "," >> "$temp_json"
|
|
164
|
+
fi
|
|
165
|
+
printf '{"repo":"%s","state":"%s","age":"%s"}' "$name" "$state" "$(date -Iseconds)" >> "$temp_json"
|
|
166
|
+
log "$state: $name"
|
|
167
|
+
fi
|
|
168
|
+
done
|
|
169
|
+
|
|
170
|
+
echo "" >> "$temp_json"
|
|
171
|
+
echo "]" >> "$temp_json"
|
|
172
|
+
mv "$temp_json" "$REPOS_STATUS"
|
|
173
|
+
log "=== cycle end ==="
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Durable fleet watchdog daemon (runs in a shell window). Ctrl-C to stop.
|
|
3
|
+
# Backs up committed + uncommitted fleet work and scans for security issues every 150s.
|
|
4
|
+
# Usage: run-watchdog.sh [--once]
|
|
5
|
+
#
|
|
6
|
+
# Configuration: export AESOP_ROOT=/path/to/aesop before running, or edit default below.
|
|
7
|
+
|
|
8
|
+
AESOP_ROOT="${AESOP_ROOT:-.}"
|
|
9
|
+
MODE="${1:-daemon}"
|
|
10
|
+
|
|
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."
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
echo "==================================================================="
|
|
20
|
+
echo " FLEET WATCHDOG DAEMON · backup + ensure-push + scan / 150s"
|
|
21
|
+
echo " logs: $AESOP_ROOT/state/FLEET-BACKUP.log · Ctrl-C to stop"
|
|
22
|
+
echo "==================================================================="
|
|
23
|
+
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
|
|
25
|
+
|
|
26
|
+
if [ "$MODE" = "--once" ]; then
|
|
27
|
+
bash "$AESOP_ROOT/daemons/backup-fleet.sh" 2>&1
|
|
28
|
+
exit 0
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
n=0
|
|
32
|
+
while true; do
|
|
33
|
+
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"
|
|
36
|
+
sleep 150
|
|
37
|
+
done
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Detect and render running Claude agents from transcripts.
|
|
2
|
+
// Usage: node dash-extra.mjs [--json]
|
|
3
|
+
// --json: output JSON array of agents (for web dashboard /data endpoint)
|
|
4
|
+
// (default): render TUI text output (for terminal dashboard)
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
// Configuration from environment
|
|
10
|
+
const AESOP_ROOT = process.env.AESOP_ROOT || path.join(process.env.HOME || '.', 'aesop');
|
|
11
|
+
const TRANSCRIPTS_ROOT = path.resolve(
|
|
12
|
+
process.env.AESOP_TRANSCRIPTS_ROOT || path.join(process.env.HOME || '.', '.claude', 'projects')
|
|
13
|
+
);
|
|
14
|
+
const SCAN_DIR = path.join(AESOP_ROOT, 'scan');
|
|
15
|
+
const ALERTS_LOG = path.join(SCAN_DIR, 'SECURITY-ALERTS.log');
|
|
16
|
+
|
|
17
|
+
// ANSI colors for TUI output
|
|
18
|
+
const c = {
|
|
19
|
+
R: '\x1b[31m',
|
|
20
|
+
G: '\x1b[32m',
|
|
21
|
+
Y: '\x1b[33m',
|
|
22
|
+
M: '\x1b[35m',
|
|
23
|
+
C: '\x1b[36m',
|
|
24
|
+
B: '\x1b[1m',
|
|
25
|
+
D: '\x1b[2m',
|
|
26
|
+
X: '\x1b[0m'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const out = [];
|
|
31
|
+
|
|
32
|
+
// Read alerts log if present
|
|
33
|
+
let slog = [];
|
|
34
|
+
try {
|
|
35
|
+
if (fs.existsSync(ALERTS_LOG)) {
|
|
36
|
+
slog = fs.readFileSync(ALERTS_LOG, 'utf8').split('\n');
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
|
|
40
|
+
// Recursively walk directory tree to find agent-*.jsonl files
|
|
41
|
+
function walk(dir, accumulator) {
|
|
42
|
+
try {
|
|
43
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const fullPath = path.join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
walk(fullPath, accumulator);
|
|
48
|
+
} else if (/^agent-.*\.jsonl$/.test(entry.name)) {
|
|
49
|
+
accumulator.push(fullPath);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Find all agent transcript files
|
|
56
|
+
let files = [];
|
|
57
|
+
if (fs.existsSync(TRANSCRIPTS_ROOT)) {
|
|
58
|
+
walk(TRANSCRIPTS_ROOT, files);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Filter: active agents in last 12 minutes, sorted by recency
|
|
62
|
+
files = files
|
|
63
|
+
.map(f => {
|
|
64
|
+
let mtime = 0;
|
|
65
|
+
try {
|
|
66
|
+
mtime = fs.statSync(f).mtimeMs;
|
|
67
|
+
} catch {}
|
|
68
|
+
return { f, mtime };
|
|
69
|
+
})
|
|
70
|
+
.filter(x => now - x.mtime < 12 * 60 * 1000)
|
|
71
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
72
|
+
.slice(0, 8);
|
|
73
|
+
|
|
74
|
+
// Extract description/hint from first ~60KB of agent transcript
|
|
75
|
+
function label(filePath) {
|
|
76
|
+
try {
|
|
77
|
+
const fd = fs.openSync(filePath, 'r');
|
|
78
|
+
const buf = Buffer.alloc(60000);
|
|
79
|
+
const n = fs.readSync(fd, buf, 0, 60000, 0);
|
|
80
|
+
fs.closeSync(fd);
|
|
81
|
+
const content = buf.toString('utf8', 0, n);
|
|
82
|
+
|
|
83
|
+
// Try to find description or subagent_type in JSON
|
|
84
|
+
let match = content.match(/"description":"([^"]{2,60})"/);
|
|
85
|
+
if (!match) {
|
|
86
|
+
match = content.match(/"subagent_type":"([^"]{2,40})"/);
|
|
87
|
+
}
|
|
88
|
+
return match ? match[1] : '';
|
|
89
|
+
} catch {}
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// TUI output: render heading
|
|
94
|
+
out.push(`${c.B} FLEET AGENTS${c.X} ${c.D}(green=running · severity-colored if flagged)${c.X}`);
|
|
95
|
+
|
|
96
|
+
if (files.length === 0) {
|
|
97
|
+
out.push(` ${c.D}(no active fleet agents in last 12 min)${c.X}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let runningCount = 0;
|
|
101
|
+
|
|
102
|
+
// Render each agent
|
|
103
|
+
for (const { f, mtime } of files) {
|
|
104
|
+
const basename = path.basename(f);
|
|
105
|
+
const ageSeconds = Math.round((now - mtime) / 1000);
|
|
106
|
+
|
|
107
|
+
// Check if agent is referenced in alerts log
|
|
108
|
+
const alertsForAgent = slog.filter(line => line.includes(basename));
|
|
109
|
+
|
|
110
|
+
let statusColor = ageSeconds < 120 ? c.G : c.D;
|
|
111
|
+
let statusText = ageSeconds < 120 ? 'running' : 'idle';
|
|
112
|
+
|
|
113
|
+
if (ageSeconds < 120) runningCount++;
|
|
114
|
+
|
|
115
|
+
// Recolor based on alert severity
|
|
116
|
+
if (alertsForAgent.some(l => l.includes('SUSPICIOUS'))) {
|
|
117
|
+
statusColor = c.R;
|
|
118
|
+
statusText = 'SUSPICIOUS';
|
|
119
|
+
} else if (alertsForAgent.some(l => / HIGH /.test(l))) {
|
|
120
|
+
statusColor = c.R;
|
|
121
|
+
statusText = 'HIGH';
|
|
122
|
+
} else if (alertsForAgent.some(l => / DRIFT /.test(l))) {
|
|
123
|
+
statusColor = c.M;
|
|
124
|
+
statusText = 'DRIFT';
|
|
125
|
+
} else if (alertsForAgent.some(l => / MED /.test(l))) {
|
|
126
|
+
statusColor = c.Y;
|
|
127
|
+
statusText = 'MED';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Extract agent ID from filename (agent-<id>.jsonl)
|
|
131
|
+
const agentId = basename
|
|
132
|
+
.replace(/^agent-/, '')
|
|
133
|
+
.replace(/\.jsonl$/, '')
|
|
134
|
+
.slice(0, 13);
|
|
135
|
+
|
|
136
|
+
const hint = label(f).slice(0, 38);
|
|
137
|
+
|
|
138
|
+
out.push(
|
|
139
|
+
` ${statusColor}●${c.X} ${agentId.padEnd(14)} ${c.D}${String(ageSeconds).padStart(4)}s${c.X} ${statusColor}${statusText.padEnd(11)}${c.X}${c.D}${hint}${c.X}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (files.length > 0) {
|
|
144
|
+
out.push(` ${c.D}${runningCount} running, ${files.length - runningCount} idle (last 12 min)${c.X}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Output
|
|
148
|
+
if (process.argv.includes('--json')) {
|
|
149
|
+
// JSON mode: emit agents array for web dashboard
|
|
150
|
+
const agents = [];
|
|
151
|
+
for (const { f, mtime } of files) {
|
|
152
|
+
const basename = path.basename(f);
|
|
153
|
+
const ageSeconds = Math.round((now - mtime) / 1000);
|
|
154
|
+
const alertsForAgent = slog.filter(line => line.includes(basename));
|
|
155
|
+
|
|
156
|
+
let status = ageSeconds < 120 ? 'running' : 'idle';
|
|
157
|
+
|
|
158
|
+
if (alertsForAgent.some(l => l.includes('SUSPICIOUS'))) {
|
|
159
|
+
status = 'SUSPICIOUS';
|
|
160
|
+
} else if (alertsForAgent.some(l => / HIGH /.test(l))) {
|
|
161
|
+
status = 'HIGH';
|
|
162
|
+
} else if (alertsForAgent.some(l => / DRIFT /.test(l))) {
|
|
163
|
+
status = 'DRIFT';
|
|
164
|
+
} else if (alertsForAgent.some(l => / MED /.test(l))) {
|
|
165
|
+
status = 'MED';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const agentId = basename
|
|
169
|
+
.replace(/^agent-/, '')
|
|
170
|
+
.replace(/\.jsonl$/, '')
|
|
171
|
+
.slice(0, 13);
|
|
172
|
+
|
|
173
|
+
agents.push({
|
|
174
|
+
id: agentId,
|
|
175
|
+
age_s: ageSeconds,
|
|
176
|
+
status: status,
|
|
177
|
+
hint: label(f).slice(0, 60)
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
process.stdout.write(JSON.stringify(agents) + '\n');
|
|
181
|
+
} else {
|
|
182
|
+
// TUI mode: emit colored text
|
|
183
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
184
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Fleet watchdog TUI dashboard. Double-buffered no-flicker render. 4s refresh. CRLF-safe (no line continuations).
|
|
3
|
+
# Launch in its own window: bash aesop/dash/watchdog-gui.sh
|
|
4
|
+
# Set AESOP_ROOT=/path/to/aesop and TRACKED_REPOS before running.
|
|
5
|
+
|
|
6
|
+
AESOP_ROOT="${AESOP_ROOT:-.}"
|
|
7
|
+
|
|
8
|
+
# Color codes (ANSI)
|
|
9
|
+
R=$'\e[31m'
|
|
10
|
+
G=$'\e[32m'
|
|
11
|
+
Y=$'\e[33m'
|
|
12
|
+
M=$'\e[35m'
|
|
13
|
+
C=$'\e[36m'
|
|
14
|
+
B=$'\e[1m'
|
|
15
|
+
D=$'\e[2m'
|
|
16
|
+
X=$'\e[0m'
|
|
17
|
+
|
|
18
|
+
# State files
|
|
19
|
+
BLOG="$AESOP_ROOT/state/FLEET-BACKUP.log"
|
|
20
|
+
SLOG="$AESOP_ROOT/state/SECURITY-ALERTS.log"
|
|
21
|
+
HB="$AESOP_ROOT/state/.watchdog-heartbeat"
|
|
22
|
+
REPOS_FILE="$AESOP_ROOT/state/.watchdog-repos.json"
|
|
23
|
+
HB_DIR="$AESOP_ROOT/state/.heartbeats"
|
|
24
|
+
|
|
25
|
+
SPINNER=0
|
|
26
|
+
FIRST_FRAME=1
|
|
27
|
+
|
|
28
|
+
# Trap Ctrl-C to restore cursor and exit cleanly
|
|
29
|
+
trap 'printf "\033[?25h"; exit 0' INT TERM
|
|
30
|
+
printf '\033[?25l'
|
|
31
|
+
|
|
32
|
+
get_hb_threshold() {
|
|
33
|
+
local name="$1"
|
|
34
|
+
case "$name" in
|
|
35
|
+
*monitor*) echo 3600;;
|
|
36
|
+
*watchdog*) echo 300;;
|
|
37
|
+
*) echo 300;;
|
|
38
|
+
esac
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
render_frame() {
|
|
42
|
+
local FRAME=""
|
|
43
|
+
FRAME="${FRAME}${B}${C}== FLEET WATCHDOG${X}\n"
|
|
44
|
+
FRAME="${FRAME} ${SPIN_CHAR} $(date '+%a %H:%M:%S') Daemon: $WD ${R}HIGH:$HI${X} ${Y}MED:$ME${X}${X}\n"
|
|
45
|
+
FRAME="${FRAME}\n"
|
|
46
|
+
FRAME="${FRAME}${B} REPOS BACKED UP${X}\n"
|
|
47
|
+
if [ -f "$REPOS_FILE" ]; then
|
|
48
|
+
repos_data=$(cat "$REPOS_FILE" 2>/dev/null)
|
|
49
|
+
if [ "$repos_data" != "[]" ] 2>/dev/null; then
|
|
50
|
+
REPOS_LINES=$(echo "$repos_data" | jq -r '.[] | " \(.repo) \(.state) \(.age)"' 2>/dev/null)
|
|
51
|
+
if [ -n "$REPOS_LINES" ]; then
|
|
52
|
+
FRAME="${FRAME}${REPOS_LINES}\n"
|
|
53
|
+
else
|
|
54
|
+
FRAME="${FRAME} ${D}(repos unavailable)${X}\n"
|
|
55
|
+
fi
|
|
56
|
+
else
|
|
57
|
+
FRAME="${FRAME} ${D}(no touched repos yet)${X}\n"
|
|
58
|
+
fi
|
|
59
|
+
else
|
|
60
|
+
FRAME="${FRAME} ${D}(no status)${X}\n"
|
|
61
|
+
fi
|
|
62
|
+
FRAME="${FRAME}\n"
|
|
63
|
+
FRAME="${FRAME}${B} HEARTBEATS${X}\n"
|
|
64
|
+
HB_FOUND=0
|
|
65
|
+
if [ -d "$HB_DIR" ] && [ -n "$(ls -A "$HB_DIR" 2>/dev/null)" ]; then
|
|
66
|
+
for hb_file in "$HB_DIR"/*; do
|
|
67
|
+
if [ -f "$hb_file" ]; then
|
|
68
|
+
name=$(basename "$hb_file")
|
|
69
|
+
epoch=$(head -1 "$hb_file" 2>/dev/null | grep -o '^[0-9]*')
|
|
70
|
+
if [ -n "$epoch" ] && [ "$epoch" -gt 0 ]; then
|
|
71
|
+
HB_FOUND=1
|
|
72
|
+
age=$(( now - epoch ))
|
|
73
|
+
threshold=$(get_hb_threshold "$name")
|
|
74
|
+
if [ "$age" -lt "$threshold" ]; then
|
|
75
|
+
status="${G}ALIVE${X} ${D}age:${age}s${X}"
|
|
76
|
+
else
|
|
77
|
+
status="${R}STALE${X} ${D}age:${age}s${X}"
|
|
78
|
+
fi
|
|
79
|
+
FRAME="${FRAME} $name $status\n"
|
|
80
|
+
fi
|
|
81
|
+
fi
|
|
82
|
+
done
|
|
83
|
+
fi
|
|
84
|
+
if [ "$HB_FOUND" -eq 0 ]; then
|
|
85
|
+
FRAME="${FRAME} ${D}(none)${X}\n"
|
|
86
|
+
fi
|
|
87
|
+
FRAME="${FRAME}\n"
|
|
88
|
+
FRAME="${FRAME}${B} RECENT EVENTS${X}\n"
|
|
89
|
+
if [ -s "$BLOG" ]; then
|
|
90
|
+
BACKUP_LINES=$(tail -3 "$BLOG" 2>/dev/null | sed 's/^/ /')
|
|
91
|
+
FRAME="${FRAME}${BACKUP_LINES}\n"
|
|
92
|
+
else
|
|
93
|
+
FRAME="${FRAME} ${D}(none)${X}\n"
|
|
94
|
+
fi
|
|
95
|
+
FRAME="${FRAME}\n"
|
|
96
|
+
FRAME="${FRAME}${D} Ctrl-C to exit · 4s refresh${X}\n"
|
|
97
|
+
echo -ne "$FRAME"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
while true; do
|
|
101
|
+
SPINNER_CHARS='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
|
102
|
+
SPINNER_IDX=$(( SPINNER % 10 ))
|
|
103
|
+
SPIN_CHAR="${SPINNER_CHARS:$SPINNER_IDX:1}"
|
|
104
|
+
((SPINNER++))
|
|
105
|
+
|
|
106
|
+
now=$(date +%s)
|
|
107
|
+
hb=$(cat "$HB" 2>/dev/null)
|
|
108
|
+
case "$hb" in
|
|
109
|
+
''|*[!0-9]*) hb=0 ;;
|
|
110
|
+
esac
|
|
111
|
+
|
|
112
|
+
if [ "$hb" -gt 0 ]; then
|
|
113
|
+
AGE=$(( now - hb ))
|
|
114
|
+
else
|
|
115
|
+
AGE=99999
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
WD_THRESH=$(get_hb_threshold "watchdog")
|
|
119
|
+
if [ "$AGE" -lt "$WD_THRESH" ]; then
|
|
120
|
+
WD="${G}ALIVE${X} ${D}(${AGE}s)${X}"
|
|
121
|
+
else
|
|
122
|
+
WD="${R}STALE${X}"
|
|
123
|
+
fi
|
|
124
|
+
|
|
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}
|
|
127
|
+
|
|
128
|
+
if [ "$FIRST_FRAME" -eq 1 ]; then
|
|
129
|
+
printf '\033[2J'
|
|
130
|
+
FIRST_FRAME=0
|
|
131
|
+
else
|
|
132
|
+
printf '\033[H'
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
render_frame | while IFS= read -r line; do
|
|
136
|
+
printf '%b\033[K\n' "$line"
|
|
137
|
+
done
|
|
138
|
+
printf '\033[J'
|
|
139
|
+
|
|
140
|
+
sleep 4
|
|
141
|
+
done
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Cardinal Rules for Aesop Orchestration
|
|
2
|
+
|
|
3
|
+
These are the foundational principles that guide all work in an Aesop-driven fleet. Violating these risks cost explosion, data loss, or orchestration breakdown.
|
|
4
|
+
|
|
5
|
+
## 1. Dispatch model & cost
|
|
6
|
+
|
|
7
|
+
**Rule**: Subagents are ALWAYS Haiku (1/3 Sonnet cost). Orchestrator (Opus) runs on main thread only.
|
|
8
|
+
|
|
9
|
+
**Why**: Haiku at scale (6–8 agents in parallel) costs ~25% of all-Opus fleet while maintaining quality on tiny scoped tasks. Opus reserved for final validation, handoff, and orchestration.
|
|
10
|
+
|
|
11
|
+
**Implementation**:
|
|
12
|
+
- When spawning a new agent, default to Haiku.
|
|
13
|
+
- If you need Opus-tier reasoning, consider decomposing into smaller Haiku tasks first.
|
|
14
|
+
- Track token spend per subagent; alert if spend deviates >20% from baseline.
|
|
15
|
+
|
|
16
|
+
## 2. TDD-first & parallel domains
|
|
17
|
+
|
|
18
|
+
**Rule**: Test-driven development: failing tests before implementation. Decompose work into tiny scoped domains; one Haiku subagent per domain in parallel.
|
|
19
|
+
|
|
20
|
+
**Why**: Small domains enable parallelism (cheaper, faster). TDD catches bugs early. Tests are living documentation.
|
|
21
|
+
|
|
22
|
+
**Implementation**:
|
|
23
|
+
- Write acceptance criteria first (in your story/ticket).
|
|
24
|
+
- Red: verify tests fail.
|
|
25
|
+
- Green: implement the minimum to pass tests.
|
|
26
|
+
- Refactor: simplify, improve, extend reusable libraries.
|
|
27
|
+
- When a task grows beyond one domain, split it and fan out to parallel Haiku agents.
|
|
28
|
+
|
|
29
|
+
## 3. Reliability core: inputs always produce outputs
|
|
30
|
+
|
|
31
|
+
**Rule**: Every input (request, event, cycle) must produce an output (brief/log/heartbeat/FAILED). Never wait silently; dispatch next work and offer ideas while waiting.
|
|
32
|
+
|
|
33
|
+
**Why**: Hangs hide cost waste, data loss, and orchestration confusion. Observable failure is better than silent lag.
|
|
34
|
+
|
|
35
|
+
**Implementation**:
|
|
36
|
+
- Daemons emit heartbeats every cycle (even on error).
|
|
37
|
+
- Logs are append-only; every action logged with timestamp.
|
|
38
|
+
- If a subagent stalls >200s, watchdog respawns it.
|
|
39
|
+
- Orchestrator briefs the user with findings while delegating to subagents (never idle).
|
|
40
|
+
|
|
41
|
+
## 4. Orchestrator isolation: lean context
|
|
42
|
+
|
|
43
|
+
**Rule**: Orchestrator reads ONLY: cardinal rules, STATE.md, BUILDLOG.md, MEMORY.md, and short git one-liners. Dispatch Haiku for research.
|
|
44
|
+
|
|
45
|
+
**Why**: Large orchestrator context = token waste, slower decisions. Brief facts + git log tell the story.
|
|
46
|
+
|
|
47
|
+
**Implementation**:
|
|
48
|
+
- Orchestrator prompt stays <2000 tokens.
|
|
49
|
+
- Long-form analysis → delegated to Haiku researcher agents.
|
|
50
|
+
- Durable checkpoints (STATE.md, BUILDLOG.md) replace ephemeral context.
|
|
51
|
+
- Prompt caching on cardinal rules + memory improves throughput.
|
|
52
|
+
|
|
53
|
+
## 5. Durable handoff: STATE.md + BUILDLOG.md
|
|
54
|
+
|
|
55
|
+
**Rule**: STATE.md tracks intent/decisions/phase/NEXT STEPS. BUILDLOG.md is append-only snapshots of agent work. On resume, re-sync from disk before acting.
|
|
56
|
+
|
|
57
|
+
**Why**: Sessions end abruptly (wipes, crashes, restarts). Git-committed state survives. Re-syncing prevents duplicate work and data loss.
|
|
58
|
+
|
|
59
|
+
**Implementation**:
|
|
60
|
+
- Before acting, orchestrator reads STATE.md (5 min old max) and BUILDLOG.md (latest 10 entries).
|
|
61
|
+
- After each major step, orchestrator updates STATE.md with new phase + next steps.
|
|
62
|
+
- Subagents append work summaries to BUILDLOG.md (one line per completion).
|
|
63
|
+
- Heartbeats mark liveness; stale heartbeats trigger re-sync.
|
|
64
|
+
|
|
65
|
+
## 6. Branch discipline & continuous push
|
|
66
|
+
|
|
67
|
+
**Rule**: Feature branches only (never main/master). Continuously push green work to origin. Never amend; create new commits.
|
|
68
|
+
|
|
69
|
+
**Why**: Main branch stays deployable. Continuous push distributes backup risk. Amending hides history.
|
|
70
|
+
|
|
71
|
+
**Implementation**:
|
|
72
|
+
- Create feature/your-task at start.
|
|
73
|
+
- Commit often (every 15–30 min of solid work).
|
|
74
|
+
- Push after every commit (github mirrors your work).
|
|
75
|
+
- Open PR when feature is ready for review.
|
|
76
|
+
- Never force-push (unless explicitly approved for specific commit).
|
|
77
|
+
|
|
78
|
+
## 7. Control files & single-writer discipline
|
|
79
|
+
|
|
80
|
+
**Rule**: MEMORY.md (keeper writes), STATE.md (orchestrator writes), BUILDLOG.md (append-only, orchestrator appends).
|
|
81
|
+
|
|
82
|
+
**Why**: Contention on shared state causes data loss and confusion. Single-writer enforcement prevents races.
|
|
83
|
+
|
|
84
|
+
**Implementation**:
|
|
85
|
+
- Designate one role per file.
|
|
86
|
+
- Use heartbeats to detect live writers; skip if <200s.
|
|
87
|
+
- On resume, read from disk; never trust in-memory state.
|
|
88
|
+
- Append-only logs never overwrite; oldest entries rotate to archives.
|
|
89
|
+
|
|
90
|
+
## 8. Secret-scan & version control
|
|
91
|
+
|
|
92
|
+
**Rule**: `secret_scan.py` blocks every push (exit 1 = blocked). No credentials in repos. Sensitive data → private remote (e.g., claude-vault).
|
|
93
|
+
|
|
94
|
+
**Why**: Credentials leak → account compromise. Scanning + gating prevents accidents.
|
|
95
|
+
|
|
96
|
+
**Implementation**:
|
|
97
|
+
- Install secret_scan.py in your aesop/tools/.
|
|
98
|
+
- Run before every push (watchdog daemon does this).
|
|
99
|
+
- If blocked, fix the issue and re-push (never --no-verify).
|
|
100
|
+
- Log blocked attempts to SECURITY-ALERTS.log; triage later.
|
|
101
|
+
|
|
102
|
+
## 9. Local execution only
|
|
103
|
+
|
|
104
|
+
**Rule**: Python runs locally only (no cloud runners). Reusable scripts live in ~/scripts (indexed in CLAUDE.md); extend existing or add genuinely reusable.
|
|
105
|
+
|
|
106
|
+
**Why**: Cloud execution = latency, cost, complexity, partial failure modes. Local = deterministic, fast, auditable.
|
|
107
|
+
|
|
108
|
+
**Implementation**:
|
|
109
|
+
- When you write a script, ask: "Will this be reused?" If yes, add to ~/scripts and index in CLAUDE.md. If no, keep in scratchpad.
|
|
110
|
+
- Never schedule Python on a cloud agent or workflow.
|
|
111
|
+
- Daemon scripts (watchdog, monitor, tooling) are canonical and live in aesop/tools/.
|
|
112
|
+
|
|
113
|
+
## 10. Observability & audit trails
|
|
114
|
+
|
|
115
|
+
**Rule**: Every agent run is logged. Every cost tracked. Every security event triaged.
|
|
116
|
+
|
|
117
|
+
**Why**: Observability reveals cost leaks, drift, and breaches. Unreviewed alerts rot into ignored noise.
|
|
118
|
+
|
|
119
|
+
**Implementation**:
|
|
120
|
+
- FLEET-LEDGER.md: one row per agent outcome (timestamp, domain, token spend, result).
|
|
121
|
+
- SECURITY-ALERTS.log: one row per security event (classified REAL/FP, triaged, archived).
|
|
122
|
+
- COST-LOG.md: periodic summaries (spend rate, drivers, regressions, optimization levers).
|
|
123
|
+
- Dashboard shows live status; triaged alerts move to RESOLVED-FP archives.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Enforcement
|
|
128
|
+
|
|
129
|
+
These rules are **guardrails-in-code**:
|
|
130
|
+
- Watchdog daemon enforces secret-scan gate and branch discipline.
|
|
131
|
+
- Monitor auto-detects violations and stages PROPOSALS.md or escalates.
|
|
132
|
+
- Orchestrator reads STATE.md to verify cardinal rule compliance.
|
|
133
|
+
- Violations caught late are logged and triaged; never silently ignored.
|
|
134
|
+
|
|
135
|
+
## What to do if a rule seems wrong
|
|
136
|
+
|
|
137
|
+
Rules are durable, but not dogmatic. If a rule creates friction:
|
|
138
|
+
|
|
139
|
+
1. Propose a change (write it to PROPOSALS.md).
|
|
140
|
+
2. Explain why it's necessary.
|
|
141
|
+
3. Ask the user for approval.
|
|
142
|
+
4. Never work around a rule; instead, propose improving it.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
**Why these rules matter**: They ensure that your orchestration fleet operates **reliably** (inputs → outputs), **cheaply** (Haiku scale), **safely** (secrets gated), and **durably** (state survives wipes). Together, they enable you to scale from 1 Opus orchestrator to dozens of parallel Haiku subagents without losing control, visibility, or cost discipline.
|