@matt82198/aesop 0.1.0-beta.1 → 0.1.0-beta.2
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/README.md +22 -2
- package/docs/CARDINAL-RULES.md +23 -13
- package/docs/DISPATCH-MODEL.md +27 -0
- package/docs/GOVERNANCE.md +162 -0
- package/monitor/CHARTER.md +71 -21
- package/monitor/collect-signals.mjs +417 -71
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/matt82198/aesop/main/assets/logo.png" alt="Aesop" width="420">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<em>Fable-Fleet Orchestration Harness</em>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/@matt82198/aesop"><img src="https://img.shields.io/npm/v/@matt82198/aesop/beta" alt="npm"></a>
|
|
11
|
+
<a href="LICENSE"><img src="https://img.shields.io/npm/l/@matt82198/aesop" alt="license"></a>
|
|
12
|
+
</p>
|
|
2
13
|
|
|
3
14
|
**Aesop** is an open-source orchestration harness for Claude Code, implementing a cost-optimized, self-healing multi-agent dispatch system. One orchestrator model (Opus/Sonnet) directs a fleet of cheap subagents (Haiku) across durable, observable machinery.
|
|
4
15
|
|
|
@@ -23,9 +34,12 @@ A filesystem-first orchestration system that:
|
|
|
23
34
|
|
|
24
35
|
### Option 1: npm (Recommended for quick scaffolding)
|
|
25
36
|
|
|
37
|
+
**Note:** Aesop is currently in beta. Install the prerelease version:
|
|
38
|
+
|
|
26
39
|
```bash
|
|
27
40
|
# Create a new aesop fleet directory
|
|
28
|
-
|
|
41
|
+
# Using @beta tag to install the latest prerelease version (0.1.0-beta.1)
|
|
42
|
+
npx @matt82198/aesop@beta my-fleet
|
|
29
43
|
cd my-fleet
|
|
30
44
|
|
|
31
45
|
# Configure
|
|
@@ -41,6 +55,12 @@ python ui/serve.py
|
|
|
41
55
|
|
|
42
56
|
Open `http://localhost:8770` to monitor your fleet.
|
|
43
57
|
|
|
58
|
+
Alternatively, install globally:
|
|
59
|
+
```bash
|
|
60
|
+
npm install -g @matt82198/aesop@beta
|
|
61
|
+
aesop my-fleet
|
|
62
|
+
```
|
|
63
|
+
|
|
44
64
|
### Option 2: git clone (For development or full customization)
|
|
45
65
|
|
|
46
66
|
```bash
|
package/docs/CARDINAL-RULES.md
CHANGED
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
|
|
3
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
4
|
|
|
5
|
-
## 1. Dispatch model & cost
|
|
5
|
+
## 1. Dispatch model & cost — Main Rule: subagents are ALWAYS cheap tier
|
|
6
6
|
|
|
7
|
-
**Rule**: Subagents are ALWAYS Haiku (1/3 Sonnet cost)
|
|
7
|
+
**Rule**: Subagents are ALWAYS Haiku (1/3 Sonnet cost) — **the single most important cost lever**. Orchestrator (Fable/Opus) runs on main thread only, NEVER spawns as a subagent, performs final-catch review itself, and NEVER hand-writes files in normal flow.
|
|
8
8
|
|
|
9
|
-
**Why**: Haiku at scale (6–8 agents in parallel) costs ~25% of all-Opus fleet while maintaining quality on tiny scoped tasks.
|
|
9
|
+
**Why**: Haiku at scale (6–8 agents in parallel) costs ~25% of all-Opus fleet while maintaining quality on tiny scoped tasks. Main thread orchestrator preserves context stability and keeps prompt cache warm across turns.
|
|
10
|
+
|
|
11
|
+
**Watchdog & stalls**: The orchestrator's core job is detecting hung Haikus. Signs: agent transcript hasn't advanced for >200s, workflow phase isn't progressing, background task should have exited but hasn't. On detection, TaskStop + relaunch the hung agent. Workflows resume from cache via `resumeFromRunId`; standalone agents respawn fresh.
|
|
12
|
+
|
|
13
|
+
**Retry cap**: 1st–3rd hang = TaskStop + relaunch automatically. On 4th hang, mark BLOCKED in BUILDLOG.md and surface to the user instead of respawning. Prevents infinite loops and ensures visibility.
|
|
10
14
|
|
|
11
15
|
**Implementation**:
|
|
12
16
|
- When spawning a new agent, default to Haiku.
|
|
13
17
|
- If you need Opus-tier reasoning, consider decomposing into smaller Haiku tasks first.
|
|
14
18
|
- Track token spend per subagent; alert if spend deviates >20% from baseline.
|
|
19
|
+
- Watchdog monitors for stalls; never let a stuck Haiku silently block a pipeline.
|
|
15
20
|
|
|
16
21
|
## 2. TDD-first & parallel domains
|
|
17
22
|
|
|
@@ -28,15 +33,18 @@ These are the foundational principles that guide all work in an Aesop-driven fle
|
|
|
28
33
|
|
|
29
34
|
## 3. Reliability core: inputs always produce outputs
|
|
30
35
|
|
|
31
|
-
**Rule**: Every input (request, event, cycle) must produce an output (brief/log/heartbeat/FAILED).
|
|
36
|
+
**Rule**: Every input (request, event, cycle) must produce an output (brief/log/heartbeat/FAILED). **NEVER WAIT**: the orchestrator never goes idle while background work is in flight — dispatch the next unblocked work unit, extend the roadmap, stage ideas, queue facts, and always offer the user an idea for what to do next. "Waiting on X" must come with "meanwhile, doing/proposing Y."
|
|
37
|
+
|
|
38
|
+
**Why**: Hangs hide cost waste, data loss, and orchestration confusion. Observable failure is better than silent lag. Idle time is token waste.
|
|
32
39
|
|
|
33
|
-
**
|
|
40
|
+
**The pride bar**: Never ship known-broken. "An agent returned a brief" and "tests pass" are checkpoints, not completion. Done means: verified end-to-end, briefs cross-checked against reality, test artifacts cleaned, loose ends closed or explicitly logged, nothing known-broken shipped silently. If you'd hesitate to hand it over under your own name, keep going.
|
|
34
41
|
|
|
35
42
|
**Implementation**:
|
|
36
43
|
- Daemons emit heartbeats every cycle (even on error).
|
|
37
44
|
- Logs are append-only; every action logged with timestamp.
|
|
38
45
|
- If a subagent stalls >200s, watchdog respawns it.
|
|
39
46
|
- Orchestrator briefs the user with findings while delegating to subagents (never idle).
|
|
47
|
+
- Before marking work done, verify end-to-end and cross-check briefs against reality.
|
|
40
48
|
|
|
41
49
|
## 4. Orchestrator isolation: lean context
|
|
42
50
|
|
|
@@ -64,28 +72,30 @@ These are the foundational principles that guide all work in an Aesop-driven fle
|
|
|
64
72
|
|
|
65
73
|
## 6. Branch discipline & continuous push
|
|
66
74
|
|
|
67
|
-
**Rule**: Feature branches only (never main/master). Continuously push green work to origin. Never amend; create new commits.
|
|
75
|
+
**Rule**: Feature branches only (never main/master). Continuously push green work to origin. **Never amend; create new commits.** **Never force-push** (unless explicitly approved for a specific commit).
|
|
68
76
|
|
|
69
|
-
**Why**: Main branch stays deployable. Continuous push distributes backup risk. Amending hides history.
|
|
77
|
+
**Why**: Main branch stays deployable. Continuous push distributes backup risk. Amending hides history; force-pushing erases commits. New commits preserve a clear audit trail.
|
|
70
78
|
|
|
71
79
|
**Implementation**:
|
|
72
80
|
- Create feature/your-task at start.
|
|
73
81
|
- Commit often (every 15–30 min of solid work).
|
|
74
82
|
- Push after every commit (github mirrors your work).
|
|
75
83
|
- Open PR when feature is ready for review.
|
|
76
|
-
-
|
|
84
|
+
- If you need to fix a commit, create a new commit with the fix (never amend).
|
|
85
|
+
- Never force-push; if you must rebase, do it on a draft branch before opening a PR.
|
|
77
86
|
|
|
78
87
|
## 7. Control files & single-writer discipline
|
|
79
88
|
|
|
80
|
-
**Rule**: MEMORY.md (keeper writes), STATE.md (orchestrator writes), BUILDLOG.md (append-only,
|
|
89
|
+
**Rule**: Single-writer control files: MEMORY.md (keeper writes only), STATE.md (orchestrator writes only), BUILDLOG.md (append-only for everyone, never overwrite). Single-instance loops check heartbeat before starting; skip if a live one exists.
|
|
81
90
|
|
|
82
|
-
**Why**: Contention on shared state causes data loss and confusion. Single-writer enforcement prevents races.
|
|
91
|
+
**Why**: Contention on shared state causes data loss and confusion. Single-writer enforcement prevents races. Append-only-for-everyone prevents accidental overwrites. Single-instance heartbeats prevent duplicate work (e.g., two memory keepers or monitors running simultaneously).
|
|
83
92
|
|
|
84
93
|
**Implementation**:
|
|
85
|
-
- Designate one
|
|
86
|
-
-
|
|
94
|
+
- Designate one writer per control file (keeper for MEMORY.md, orchestrator for STATE.md).
|
|
95
|
+
- BUILDLOG.md is append-only; anyone can append, no one edits earlier entries.
|
|
96
|
+
- Before starting a loop, check `~/.claude/loops/<loop-name>.heartbeat`; if exists and recent (<5 min old), skip and exit.
|
|
87
97
|
- On resume, read from disk; never trust in-memory state.
|
|
88
|
-
- Append-only logs never overwrite; oldest entries rotate to archives.
|
|
98
|
+
- Append-only logs never overwrite; oldest entries rotate to archives when >200 lines/20KB.
|
|
89
99
|
|
|
90
100
|
## 8. Secret-scan & version control
|
|
91
101
|
|
package/docs/DISPATCH-MODEL.md
CHANGED
|
@@ -33,6 +33,19 @@ Total = 1,080 tokens
|
|
|
33
33
|
= **90% savings** vs 10 Opus tasks
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
## MAIN RULE: Subagents are ALWAYS the cheap tier
|
|
37
|
+
|
|
38
|
+
This is **the single most important cost lever** in the entire system. Every subagent spawned must default to Haiku, never Sonnet or Opus. This rule, more than any other, multiplies your savings at scale.
|
|
39
|
+
|
|
40
|
+
**Why**: Haiku is 1/3 the cost of Sonnet with sufficient capability for scoped domain work. Scaling from 1 subagent to 6–8 in parallel multiplies the per-domain savings — what would cost 10× Opus now costs <1×. Violating this rule (spawning Sonnet/Opus subagents) erases the economic advantage of the entire dispatch model.
|
|
41
|
+
|
|
42
|
+
**The exception**: Only use Sonnet/Opus for a subagent if:
|
|
43
|
+
1. The task genuinely exceeds Haiku capability (rare for scoped domains), **AND**
|
|
44
|
+
2. You've decomposed it as far as possible and still can't fit it into Haiku, **AND**
|
|
45
|
+
3. You have explicit approval to do so.
|
|
46
|
+
|
|
47
|
+
Even then, use Sonnet as a supervisor only (splits work into Haiku subdomains), never Opus as a subagent.
|
|
48
|
+
|
|
36
49
|
## Dispatch patterns
|
|
37
50
|
|
|
38
51
|
### Pattern 1: Fan-out (wide parallelism)
|
|
@@ -146,6 +159,20 @@ Is this task scoped to <5 min reasoning?
|
|
|
146
159
|
2. **Respawn loop** (same domain 3+ times): subagent repeatedly failing; escalate to Sonnet or orchestrator review.
|
|
147
160
|
3. **Serial bottleneck** (orchestrator >500 tokens): parallelism breaking down; split smaller or delegate.
|
|
148
161
|
|
|
162
|
+
## Retry cap: Automatic recovery + escalation
|
|
163
|
+
|
|
164
|
+
The orchestrator's watchdog automatically detects hung agents and relaunches them with the same scoped prompt:
|
|
165
|
+
|
|
166
|
+
- **1st–3rd hang**: TaskStop + relaunch automatically. Workflows resume from cache via `resumeFromRunId`; standalone agents respawn fresh.
|
|
167
|
+
- **4th hang**: Mark BLOCKED in BUILDLOG.md and surface to the user instead of respawning.
|
|
168
|
+
|
|
169
|
+
**Why the cap?** Infinite retry loops waste tokens and hide systemic problems. After 3 attempts, a persistent hang indicates either:
|
|
170
|
+
- Task is fundamentally unscopable (too large, too complex)
|
|
171
|
+
- Subagent needs a larger model (escalate to Sonnet or Opus)
|
|
172
|
+
- External dependency is broken (requires human intervention)
|
|
173
|
+
|
|
174
|
+
Surfacing to the user after 3 retries ensures visibility and prevents silent cost bleed.
|
|
175
|
+
|
|
149
176
|
### Optimization levers
|
|
150
177
|
|
|
151
178
|
- **Fan-out more tasks**: go from 2 Haiku to 5.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Governance & Control
|
|
2
|
+
|
|
3
|
+
This guide covers the operational patterns that keep orchestration systems coherent, scalable, and auditable at production scale: single-instance loops, single-writer files, asynchronous inbox coordination, log rotation, branching discipline, and security gates.
|
|
4
|
+
|
|
5
|
+
## Single-instance loops
|
|
6
|
+
|
|
7
|
+
Standing loops (watchdog, monitor, memory keeper, cost tracker, QA verification) must not run in duplicate — that causes contention, conflicting writes, and wasted work.
|
|
8
|
+
|
|
9
|
+
**Pattern**: Every loop checks its own heartbeat file before starting and skips the start if a live one exists.
|
|
10
|
+
|
|
11
|
+
### Heartbeat protocol
|
|
12
|
+
|
|
13
|
+
1. Before loop starts, check `~/.claude/loops/<loop-name>.heartbeat`
|
|
14
|
+
2. If exists and recent (<5 min old), skip and exit
|
|
15
|
+
3. If missing or stale, create/touch heartbeat file, run loop, delete heartbeat on exit
|
|
16
|
+
|
|
17
|
+
**Example** (pseudo-code):
|
|
18
|
+
```bash
|
|
19
|
+
HEARTBEAT=~/.claude/loops/memory-keeper.heartbeat
|
|
20
|
+
if [ -f "$HEARTBEAT" ] && [ $(($(date +%s) - $(stat -c %Y "$HEARTBEAT"))) -lt 300 ]; then
|
|
21
|
+
exit 0 # Live keeper running, skip
|
|
22
|
+
fi
|
|
23
|
+
touch "$HEARTBEAT"
|
|
24
|
+
# ... run loop ...
|
|
25
|
+
rm "$HEARTBEAT"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Why**: Prevents duplicate work, keeps system coherent, and ensures idempotent restarts.
|
|
29
|
+
|
|
30
|
+
## Single-writer control files
|
|
31
|
+
|
|
32
|
+
Some files must have exactly one writer to prevent race conditions and data loss.
|
|
33
|
+
|
|
34
|
+
**Writers by role**:
|
|
35
|
+
- **MEMORY.md**: memory keeper only
|
|
36
|
+
- **STATE.md**: active orchestrator only
|
|
37
|
+
- **BUILDLOG.md**: append-only (anyone can append, no one edits earlier entries)
|
|
38
|
+
- **Other loops**: append requests to an inbox file, never edit control files directly
|
|
39
|
+
|
|
40
|
+
**Why**: Contention on shared state causes corruption. Single-writer enforcement makes all edits safe.
|
|
41
|
+
|
|
42
|
+
## Inbox pattern for coordination
|
|
43
|
+
|
|
44
|
+
Loops that need to request memory updates or signal decisions use an append-only inbox. The orchestrator reviews inbox entries on every `/power` cycle.
|
|
45
|
+
|
|
46
|
+
### Inbox format
|
|
47
|
+
|
|
48
|
+
1. Create `~/.claude/INBOX.md` (append-only, anyone can append)
|
|
49
|
+
2. Format: `[TIMESTAMP] <loop-name>: <request>`
|
|
50
|
+
3. On every `/power`, orchestrator reviews entries, acts on them, moves handled ones to an ACCEPTED/REJECTED log
|
|
51
|
+
4. Clear INBOX.md when processed
|
|
52
|
+
|
|
53
|
+
**Example**:
|
|
54
|
+
```
|
|
55
|
+
[2026-07-11 13:45] cost-loop: log monthly spend update to MEMORY.md
|
|
56
|
+
[2026-07-11 14:00] qa-loop: mark feature X as verified, move to shipped log
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Why**: Decouples loop work from orchestrator; loops queue requests without blocking; orchestrator batches decisions on a predictable cadence.
|
|
60
|
+
|
|
61
|
+
## Log rotation threshold
|
|
62
|
+
|
|
63
|
+
Append-only control files grow over time. When a file exceeds **~200 lines or ~20 KB**, rotate the oldest entries into a dated archive.
|
|
64
|
+
|
|
65
|
+
### Rotation procedure
|
|
66
|
+
|
|
67
|
+
1. Copy oldest entries (typically oldest 50%) to `<FILE>-YYYY-MM.md`
|
|
68
|
+
2. Keep only recent entries in the live `<FILE>.md`
|
|
69
|
+
3. Commit both old and new files
|
|
70
|
+
4. Update any references to point to the live file
|
|
71
|
+
|
|
72
|
+
**Examples**:
|
|
73
|
+
- `BUILDLOG.md` → `BUILDLOG-2026-06.md` (archive) + new `BUILDLOG.md` (live)
|
|
74
|
+
- `COST-LOG.md` → `COST-LOG-2026-06.md` (archive) + new `COST-LOG.md` (live)
|
|
75
|
+
|
|
76
|
+
**Why**: Keeps orchestrator context bounded and responsive. Orchestrator reads only recent ~20 entries; old data lives in archives.
|
|
77
|
+
|
|
78
|
+
## Branch workflow: never straight to main
|
|
79
|
+
|
|
80
|
+
**Production-grade branching** ensures code review, testing, and safety gates on all production merges.
|
|
81
|
+
|
|
82
|
+
### Feature & fix branches
|
|
83
|
+
|
|
84
|
+
- **Features**: `feat/<topic>` branches off main
|
|
85
|
+
- **Fixes**: `fix/<issue-number>` branches off main
|
|
86
|
+
- **All work**: Branches first, PR after implementation, merge after final-catch review
|
|
87
|
+
|
|
88
|
+
**Never** push directly to main/master. All changes go through:
|
|
89
|
+
1. Feature/fix branch
|
|
90
|
+
2. Open PR
|
|
91
|
+
3. Orchestrator final-catch review
|
|
92
|
+
4. Merge into main
|
|
93
|
+
|
|
94
|
+
### Session branches for infrastructure repos
|
|
95
|
+
|
|
96
|
+
- Daily work on infrastructure repos (e.g., `~/.claude`): use `work/session-<date>` branches (e.g., `work/session-2026-07-11`)
|
|
97
|
+
- Auto-commit hooks commit to the current branch, so housekeeping changes land on the session branch automatically
|
|
98
|
+
- On checkpoint, open a PR from session branch → main
|
|
99
|
+
- Orchestrator performs final-catch review and merges when ready
|
|
100
|
+
|
|
101
|
+
### Backup branches
|
|
102
|
+
|
|
103
|
+
Emergency backup snapshots use `backup/<snapshot-label>` branches. Never merge backups to main; recovery is via cherry-pick from backup → feature → PR → merge.
|
|
104
|
+
|
|
105
|
+
**Why**: Main branch stays deployable. All work visible in PRs. History auditable. Accidental commits prevented.
|
|
106
|
+
|
|
107
|
+
## Secret-scan gate on every push
|
|
108
|
+
|
|
109
|
+
**Critical rule**: Every push (to any repo, any branch) is preceded by:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
python ~/scripts/secret_scan.py --staged --repo <repo_path>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Gate logic
|
|
116
|
+
|
|
117
|
+
- **Exit code 0**: safe, proceed with push
|
|
118
|
+
- **Exit code 1**: secrets detected, block push until resolved
|
|
119
|
+
|
|
120
|
+
### What it detects
|
|
121
|
+
|
|
122
|
+
- AWS/API keys, OAuth tokens, credentials
|
|
123
|
+
- Private keys and SSH keys
|
|
124
|
+
- Hardcoded passwords or session tokens
|
|
125
|
+
- Sensitive file patterns
|
|
126
|
+
|
|
127
|
+
### On blocked push
|
|
128
|
+
|
|
129
|
+
1. Fix the staged files (remove secrets)
|
|
130
|
+
2. Re-run secret_scan to verify clean
|
|
131
|
+
3. Re-push (no `--no-verify` or workarounds)
|
|
132
|
+
|
|
133
|
+
**Why**: Credentials leak → account/system compromise. Scanning + gating prevents accidents and keeps repos clean.
|
|
134
|
+
|
|
135
|
+
## Version control for the brain
|
|
136
|
+
|
|
137
|
+
Infrastructure repos (`~/.claude` for rules/hooks, `~/scripts` for shared scripts, handoff folders) are version-controlled and pushed to private remotes.
|
|
138
|
+
|
|
139
|
+
**Discipline**:
|
|
140
|
+
1. Every rule/hook/skill/memory change is committed and pushed as it lands
|
|
141
|
+
2. On every `/power` session:
|
|
142
|
+
- Verify repos are clean (no uncommitted changes)
|
|
143
|
+
- Verify pushed to remote (no commits ahead)
|
|
144
|
+
- If unexpected changes found, restore from remote before continuing
|
|
145
|
+
3. Never commit credentials, tokens, session data, or large binaries
|
|
146
|
+
|
|
147
|
+
**Why**: Infrastructure is durable, recoverable, and auditable. Unexpected changes to rules are a Tier-1 incident.
|
|
148
|
+
|
|
149
|
+
## Violations and escalation
|
|
150
|
+
|
|
151
|
+
Governance violations are detected and escalated:
|
|
152
|
+
|
|
153
|
+
1. **Watchdog daemon** enforces secret-scan gate and branch discipline
|
|
154
|
+
2. **Monitor agent** auto-detects control-file violations and stages PROPOSALS.md or escalates
|
|
155
|
+
3. **Orchestrator** reads STATE.md to verify governance compliance
|
|
156
|
+
4. **Violations caught late** are logged and triaged; never silently ignored
|
|
157
|
+
|
|
158
|
+
If a rule creates friction, propose a change via PROPOSALS.md (never work around it).
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
**Why these patterns matter**: They ensure your orchestration fleet operates **reliably** (inputs → outputs), **safely** (no secret leaks), **coherently** (no data loss from races), and **durably** (recoverable from failures). Together, they enable scaling from 1 orchestrator to dozens of parallel subagents without losing control or visibility.
|
package/monitor/CHARTER.md
CHANGED
|
@@ -9,26 +9,59 @@ A standing background monitor (Haiku subagent) that watches how the multi-agent
|
|
|
9
9
|
> or invent new project goals. If it ever thinks the goal should change, it writes a note
|
|
10
10
|
> in `PROPOSALS.md` and stops there.
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Standing checks (deterministic signal collection)
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
Each cycle, `collect-signals.mjs` runs these checks and emits `BRIEF.md` + `SIGNALS.json`:
|
|
15
|
+
|
|
16
|
+
1. **Heartbeat liveness check** — read `.heartbeats/*` (epoch on line 1) + legacy beat files
|
|
17
|
+
(`.monitor-heartbeat`, `.watchdog-heartbeat`); flag any older than threshold (watchdog 300s,
|
|
18
|
+
monitor 3600s, others 1800s default) as stale loops; append warning to BRIEF.md.
|
|
19
|
+
|
|
20
|
+
2. **Git state across repos** — for each repo in config: branch, last commit, uncommitted files,
|
|
21
|
+
commits ahead of remote. Identifies unpushed work and dirty state.
|
|
22
|
+
|
|
23
|
+
3. **Memory freshness** — scan project memory files (excluding index/inbox); flag any >30 days old;
|
|
24
|
+
append summary to BRIEF.md ("N memories stale — keeper should re-verify").
|
|
25
|
+
|
|
26
|
+
4. **Log rotation** — check append-only logs against max-lines/max-kb thresholds (configurable,
|
|
27
|
+
default 500 lines / 40 KB). If a log exceeds threshold, emit rotation signal (AUTO tier can
|
|
28
|
+
invoke rotate_logs.py if available); append rotation summary to BRIEF.md.
|
|
29
|
+
|
|
30
|
+
5. **Junk-script sprawl detection** — scan temp/scratch roots for throwaway `.py`/.mjs`/`.js` files
|
|
31
|
+
older than 24 hours, not in a live session directory (avoid false-positives during active work).
|
|
32
|
+
Count total, estimate quarantinable, list oldest. AUTO tier quarantines confirmed junk into
|
|
33
|
+
`monitor/quarantine/` with manifest.
|
|
34
|
+
|
|
35
|
+
6. **Stray scripts in repo roots** — scan recent commits (7d) to detect one-off `.py`/`.mjs`/`.sql`
|
|
36
|
+
scripts committed directly to repo root (not under proper src/scripts paths). Flag for cleanup.
|
|
37
|
+
|
|
38
|
+
7. **Security alert review loop** — tail SECURITY-ALERTS.log for new HIGH/MED entries (skip suppressed);
|
|
39
|
+
each new alert noted in BRIEF.md. Monitor agent does semantic review (REAL vs FP) each cycle.
|
|
40
|
+
|
|
41
|
+
8. **Respawn watch (Rule 6 retry cap)** — parse agent spawn records; normalize descriptions to
|
|
42
|
+
prompt-signatures (first ~40 chars, lowercased); count occurrences in recent window (last 50 rows).
|
|
43
|
+
Flag any signature appearing >3 times as probable hung-agent loop → rule 6 cap breached.
|
|
44
|
+
Limitation: heuristic may have false positives for intentional similar-task fan-outs.
|
|
45
|
+
|
|
46
|
+
9. **Cost cadence tracking** — every 3rd cycle, harvest agent spawn ledger and append cost tick
|
|
47
|
+
to COST-LOG.md (cycle count, timestamp, model distribution). Flags non-haiku specializations.
|
|
48
|
+
|
|
49
|
+
10. **Unreviewed agent prompts** — run fleet-prompt-extractor.py (if available) to collect NEW
|
|
50
|
+
spawns since last review; emit count in SIGNALS.json + note in BRIEF.md.
|
|
19
51
|
|
|
20
52
|
## Action tiers
|
|
21
53
|
|
|
22
54
|
- **AUTO (apply immediately, then log to `ACTIONS.log`):**
|
|
23
55
|
- Heartbeat freshness checks (read-only).
|
|
24
|
-
- Log rotation (invoke
|
|
25
|
-
- Persist
|
|
26
|
-
- Quarantine
|
|
56
|
+
- Log rotation (invoke rotate_logs.py if available; fail-open if not).
|
|
57
|
+
- Persist heartbeat write (`.monitor-heartbeat` update after cycle).
|
|
58
|
+
- Quarantine confirmed-junk scripts: move old temp `.py`/`.mjs` (not live session) into
|
|
59
|
+
`monitor/quarantine/` with manifest — never delete outright.
|
|
27
60
|
|
|
28
61
|
- **PROPOSE (write to `PROPOSALS.md`, do NOT apply — needs user approval):**
|
|
29
62
|
- Changes to cardinal rules or agent configuration.
|
|
30
|
-
- Alterations to agent behavior or
|
|
31
|
-
- Deletions of anything outside
|
|
63
|
+
- Alterations to agent behavior, model choice, or push targets.
|
|
64
|
+
- Deletions of anything outside monitor's own quarantine.
|
|
32
65
|
- Any change to orchestration policy.
|
|
33
66
|
|
|
34
67
|
## Hard guardrails (inviolable)
|
|
@@ -36,31 +69,48 @@ A standing background monitor (Haiku subagent) that watches how the multi-agent
|
|
|
36
69
|
- Preserve the FIXED GOAL. Improve rules; never rewrite intent.
|
|
37
70
|
- **Subagents are ALWAYS Haiku** (cost optimization). Orchestrator on main thread only.
|
|
38
71
|
- NEVER push destructively or to restricted remotes as an AUTO action.
|
|
72
|
+
- NEVER edit cardinal-rules files (CLAUDE.md, skills/, agent configs) — only PROPOSE changes.
|
|
39
73
|
- Idempotent + additive: safe to run repeatedly; prefer append over overwrite.
|
|
40
74
|
- Stay cheap: read the signal brief, not raw logs. One tight cycle, then sleep.
|
|
75
|
+
- Robust to missing files: treat missing dirs/logs as empty, never crash.
|
|
41
76
|
|
|
42
77
|
## Outputs
|
|
43
78
|
|
|
44
|
-
- `BRIEF.md` — human-readable status snapshot.
|
|
45
|
-
- `SIGNALS.json` — machine-readable metrics and findings.
|
|
79
|
+
- `BRIEF.md` — human-readable status snapshot (overwrite each cycle).
|
|
80
|
+
- `SIGNALS.json` — machine-readable metrics and findings (overwrite each cycle).
|
|
46
81
|
- `ACTIONS.log` — append-only record of AUTO actions taken (timestamped).
|
|
47
82
|
- `PROPOSALS.md` — staged changes awaiting user approval.
|
|
48
|
-
- `quarantine/`
|
|
83
|
+
- `quarantine/` — parked junk scripts + `MANIFEST.tsv` (append-only).
|
|
84
|
+
- `.monitor-heartbeat` — epoch timestamp (line 1) for liveness.
|
|
85
|
+
- `.signal-state.json` — sidecar state (cycle count, seen prompts, etc.).
|
|
49
86
|
|
|
50
87
|
## Operating it
|
|
51
88
|
|
|
52
|
-
1. **Deploy the monitor**
|
|
53
|
-
2. **
|
|
89
|
+
1. **Deploy the monitor** as a continuous background task (or invoke manually each cycle).
|
|
90
|
+
2. **Customize via config**: populate `aesop.config.json` with your repo paths, thresholds.
|
|
54
91
|
3. **Review PROPOSALS.md** periodically; approve changes or reject them.
|
|
55
92
|
4. **Check ACTIONS.log** to see what was automated.
|
|
56
|
-
5. **
|
|
93
|
+
5. **Monitor BRIEF.md** for drift signals; respond to findings.
|
|
57
94
|
|
|
58
95
|
## Customization
|
|
59
96
|
|
|
60
97
|
Edit `collect-signals.mjs` to:
|
|
61
|
-
- Add
|
|
62
|
-
-
|
|
98
|
+
- Add/remove checks based on your project needs.
|
|
99
|
+
- Configure paths via environment variables (AESOP_ROOT, BRAIN_ROOT, SCRIPTS_ROOT, TEMP_ROOT)
|
|
100
|
+
or load from aesop.config.json.
|
|
63
101
|
- Set heartbeat thresholds appropriate to your workflow.
|
|
64
|
-
- Integrate
|
|
102
|
+
- Integrate custom signal collectors (e.g., compliance checks, custom linters).
|
|
103
|
+
|
|
104
|
+
See comments in `collect-signals.mjs` for extension points. **Keep it CRLF-safe** (no line
|
|
105
|
+
continuations; maintain Windows+POSIX compatibility).
|
|
106
|
+
|
|
107
|
+
## Single-instance guard
|
|
108
|
+
|
|
109
|
+
Before running, check `.monitor-heartbeat` — if <300s old, skip cycle (another monitor is running).
|
|
110
|
+
After cycle completes, update `.monitor-heartbeat` with current epoch.
|
|
111
|
+
|
|
112
|
+
## Single-writer discipline
|
|
65
113
|
|
|
66
|
-
|
|
114
|
+
- Only the monitor edits `BRIEF.md`, `SIGNALS.json`, `ACTIONS.log`, `.monitor-heartbeat`, `.signal-state.json`.
|
|
115
|
+
- External processes (human, other agents) write to `PROPOSALS.md` (append or structured inbox).
|
|
116
|
+
- Quarantine manifest is append-only; never edit entries, only add new ones.
|
|
@@ -4,14 +4,41 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { execSync } from 'node:child_process';
|
|
7
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
8
8
|
|
|
9
|
+
// === Configuration ===
|
|
10
|
+
// Load from environment or aesop.config.json; fall back to safe defaults.
|
|
9
11
|
const AESOP_ROOT = process.env.AESOP_ROOT || '.';
|
|
12
|
+
const BRAIN_ROOT = process.env.BRAIN_ROOT || path.join(AESOP_ROOT, '..', '.claude');
|
|
13
|
+
const SCRIPTS_ROOT = process.env.SCRIPTS_ROOT || path.join(AESOP_ROOT, '..', 'scripts');
|
|
14
|
+
const TEMP_ROOT = process.env.TEMP_ROOT || path.join(AESOP_ROOT, '..', 'AppData', 'Local', 'Temp', 'claude');
|
|
10
15
|
const MON = path.join(AESOP_ROOT, 'monitor');
|
|
16
|
+
const STATE_DIR = path.join(AESOP_ROOT, 'state');
|
|
17
|
+
|
|
18
|
+
// Optional: load aesop.config.json for repo list
|
|
19
|
+
let repos = [];
|
|
20
|
+
let logThresholds = { maxLines: 500, maxKb: 40 };
|
|
21
|
+
try {
|
|
22
|
+
const configPath = path.join(AESOP_ROOT, 'aesop.config.json');
|
|
23
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
24
|
+
if (config.repos && Array.isArray(config.repos)) {
|
|
25
|
+
repos = config.repos.map(r => r.path);
|
|
26
|
+
}
|
|
27
|
+
if (config.monitor && config.monitor.log_max_lines) {
|
|
28
|
+
logThresholds.maxLines = config.monitor.log_max_lines;
|
|
29
|
+
}
|
|
30
|
+
if (config.monitor && config.monitor.log_max_kb) {
|
|
31
|
+
logThresholds.maxKb = config.monitor.log_max_kb;
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// No config file or parse error; use defaults
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
const now = Date.now();
|
|
12
38
|
const HOUR = 3600e3;
|
|
13
39
|
const DAY = 24 * HOUR;
|
|
14
40
|
|
|
41
|
+
// === Utilities ===
|
|
15
42
|
const sh = (cmd, cwd) => {
|
|
16
43
|
try {
|
|
17
44
|
return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
@@ -34,103 +61,422 @@ const age = (ms) => {
|
|
|
34
61
|
return `${(ms / DAY).toFixed(1)}d`;
|
|
35
62
|
};
|
|
36
63
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
64
|
+
const walk = (dir, test, out = [], depth = 0) => {
|
|
65
|
+
if (depth > 6) return out;
|
|
66
|
+
let ents;
|
|
67
|
+
try {
|
|
68
|
+
ents = fs.readdirSync(dir, { withFileTypes: true });
|
|
69
|
+
} catch {
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
for (const e of ents) {
|
|
73
|
+
const fp = path.join(dir, e.name);
|
|
74
|
+
if (e.isDirectory()) {
|
|
75
|
+
if (/node_modules|\.git|target|\.venv|__pycache__/.test(e.name)) continue;
|
|
76
|
+
walk(fp, test, out, depth + 1);
|
|
77
|
+
} else if (test(e.name, fp)) {
|
|
78
|
+
out.push(fp);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
44
82
|
};
|
|
45
83
|
|
|
46
|
-
|
|
47
|
-
`# Aesop Monitor Brief — ${new Date(now).toISOString()}`,
|
|
48
|
-
'',
|
|
49
|
-
'## Status',
|
|
50
|
-
'Orchestration monitor cycle complete.',
|
|
51
|
-
'',
|
|
52
|
-
];
|
|
84
|
+
// === Signal Collectors ===
|
|
53
85
|
|
|
54
86
|
// 1) Heartbeat check
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
beatFiles =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
87
|
+
function checkHeartbeats() {
|
|
88
|
+
const staleLoops = [];
|
|
89
|
+
const beatsDir = path.join(MON, '.heartbeats');
|
|
90
|
+
const thresholds = { watchdog: 300e3, monitor: 3600e3, default: 1800e3 };
|
|
91
|
+
let beatFiles = [];
|
|
92
|
+
try {
|
|
93
|
+
beatFiles = fs.readdirSync(beatsDir).map(f => path.join(beatsDir, f));
|
|
94
|
+
} catch {
|
|
95
|
+
// no .heartbeats dir
|
|
96
|
+
}
|
|
97
|
+
const legacyBeats = [
|
|
98
|
+
path.join(MON, '.monitor-heartbeat'),
|
|
99
|
+
path.join(STATE_DIR, '.watchdog-heartbeat'),
|
|
100
|
+
];
|
|
101
|
+
beatFiles = beatFiles.concat(legacyBeats.filter(f => fs.existsSync(f)));
|
|
102
|
+
for (const fp of beatFiles) {
|
|
103
|
+
try {
|
|
104
|
+
const content = fs.readFileSync(fp, 'utf8').trim();
|
|
105
|
+
const epoch = parseInt(content.split('\n')[0], 10);
|
|
106
|
+
if (!epoch) continue;
|
|
107
|
+
const beatAge = now - epoch * 1000;
|
|
108
|
+
const name = path.basename(fp);
|
|
109
|
+
let threshold = thresholds.default;
|
|
110
|
+
if (name.includes('watchdog')) threshold = thresholds.watchdog;
|
|
111
|
+
if (name.includes('monitor')) threshold = thresholds.monitor;
|
|
112
|
+
if (beatAge > threshold) {
|
|
113
|
+
staleLoops.push({ name, ageMs: beatAge, threshold });
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// skip invalid heartbeat file
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return staleLoops;
|
|
120
|
+
}
|
|
66
121
|
|
|
67
|
-
|
|
122
|
+
// 2) Git state check
|
|
123
|
+
function checkGitState() {
|
|
124
|
+
const gitState = [];
|
|
125
|
+
for (const repoPath of repos) {
|
|
126
|
+
if (!fs.existsSync(repoPath)) continue;
|
|
127
|
+
const branch = sh('git rev-parse --abbrev-ref HEAD', repoPath);
|
|
128
|
+
const lastCommit = sh('git log -1 --pretty=%h·%cr·%s', repoPath);
|
|
129
|
+
const dirty = sh('git status --porcelain', repoPath)
|
|
130
|
+
.split('\n')
|
|
131
|
+
.filter(l => l.trim()).length;
|
|
132
|
+
const ahead = sh('git rev-list --count @{u}..HEAD', repoPath) || '?';
|
|
133
|
+
gitState.push({
|
|
134
|
+
repo: path.basename(repoPath),
|
|
135
|
+
branch,
|
|
136
|
+
lastCommit,
|
|
137
|
+
dirty,
|
|
138
|
+
ahead,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return gitState;
|
|
142
|
+
}
|
|
68
143
|
|
|
69
|
-
|
|
144
|
+
// 3) Memory freshness check
|
|
145
|
+
function checkMemoryFreshness() {
|
|
146
|
+
const memoryDir = path.join(BRAIN_ROOT, 'projects', '*', 'memory');
|
|
147
|
+
const staleMemories = [];
|
|
148
|
+
let memoryCount = 0;
|
|
70
149
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
150
|
+
const baseDir = path.join(BRAIN_ROOT, 'projects');
|
|
151
|
+
if (!fs.existsSync(baseDir)) return { count: 0, staleMemories: [], staleCount: 0 };
|
|
152
|
+
const projDirs = fs.readdirSync(baseDir, { withFileTypes: true })
|
|
153
|
+
.filter(d => d.isDirectory())
|
|
154
|
+
.map(d => path.join(baseDir, d.name, 'memory'));
|
|
155
|
+
for (const memDir of projDirs) {
|
|
156
|
+
if (!fs.existsSync(memDir)) continue;
|
|
157
|
+
const memFiles = fs.readdirSync(memDir)
|
|
158
|
+
.filter(f => f.endsWith('.md') && f !== 'MEMORY.md' && f !== 'INBOX.md')
|
|
159
|
+
.map(f => path.join(memDir, f));
|
|
160
|
+
memoryCount += memFiles.length;
|
|
161
|
+
for (const fp of memFiles) {
|
|
162
|
+
const st = stat(fp);
|
|
163
|
+
if (st && now - st.mtimeMs > 30 * DAY) {
|
|
164
|
+
staleMemories.push(path.basename(fp));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// no memory dir
|
|
170
|
+
}
|
|
171
|
+
return { count: memoryCount, staleMemories, staleCount: staleMemories.length };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 4) Log file status check
|
|
175
|
+
function checkLogFiles() {
|
|
176
|
+
const logFiles = [
|
|
177
|
+
path.join(STATE_DIR, 'FLEET-BACKUP.log'),
|
|
178
|
+
path.join(STATE_DIR, 'SECURITY-ALERTS.log'),
|
|
179
|
+
path.join(MON, 'ACTIONS.log'),
|
|
180
|
+
];
|
|
181
|
+
const logs = [];
|
|
182
|
+
for (const logPath of logFiles) {
|
|
183
|
+
const st = stat(logPath);
|
|
184
|
+
let sizeKb = 0;
|
|
185
|
+
let lineCount = 0;
|
|
186
|
+
if (st) {
|
|
187
|
+
sizeKb = (st.size / 1024).toFixed(1);
|
|
188
|
+
try {
|
|
189
|
+
const content = fs.readFileSync(logPath, 'utf8');
|
|
190
|
+
lineCount = content.split('\n').filter(l => l.trim()).length;
|
|
191
|
+
} catch {
|
|
192
|
+
// skip
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
logs.push({
|
|
196
|
+
name: path.basename(logPath),
|
|
197
|
+
exists: !!st,
|
|
198
|
+
sizeKb,
|
|
199
|
+
lineCount,
|
|
200
|
+
needsRotation: st && (lineCount > logThresholds.maxLines || st.size / 1024 > logThresholds.maxKb),
|
|
87
201
|
});
|
|
88
|
-
}
|
|
202
|
+
}
|
|
203
|
+
return logs;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 5) Junk-script sprawl detection
|
|
207
|
+
function detectJunkScripts() {
|
|
208
|
+
if (!fs.existsSync(TEMP_ROOT)) return { total: 0, quarantinable: 0, bytes: 0, oldest: [], recentCount: 0 };
|
|
209
|
+
const sessionDirs = (() => {
|
|
210
|
+
try {
|
|
211
|
+
return fs.readdirSync(TEMP_ROOT, { withFileTypes: true })
|
|
212
|
+
.filter(d => d.isDirectory())
|
|
213
|
+
.map(d => path.join(TEMP_ROOT, d.name));
|
|
214
|
+
} catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
})();
|
|
218
|
+
const liveDirs = new Set();
|
|
219
|
+
for (const root of sessionDirs) {
|
|
220
|
+
const files = walk(root, () => true);
|
|
221
|
+
const newest = Math.max(0, ...files.map(fp => (stat(fp) || { mtimeMs: 0 }).mtimeMs));
|
|
222
|
+
if (now - newest < 2 * HOUR) {
|
|
223
|
+
liveDirs.add(root.replace(/\\/g, '/'));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const inLiveDir = (fp) => [...liveDirs].some(d => fp.replace(/\\/g, '/').startsWith(d));
|
|
227
|
+
const tempScripts = walk(TEMP_ROOT, n => /\.(py|mjs|js)$/.test(n))
|
|
228
|
+
.map(fp => ({ fp, st: stat(fp) }))
|
|
229
|
+
.filter(x => x.st)
|
|
230
|
+
.map(x => ({
|
|
231
|
+
fp: x.fp,
|
|
232
|
+
ageMs: now - x.st.mtimeMs,
|
|
233
|
+
size: x.st.size,
|
|
234
|
+
quarantinable: now - x.st.mtimeMs > DAY && !inLiveDir(x.fp),
|
|
235
|
+
}));
|
|
236
|
+
const junk = {
|
|
237
|
+
total: tempScripts.length,
|
|
238
|
+
quarantinable: tempScripts.filter(x => x.quarantinable).length,
|
|
239
|
+
bytes: tempScripts.reduce((a, x) => a + x.size, 0),
|
|
240
|
+
oldest: tempScripts
|
|
241
|
+
.sort((a, b) => b.ageMs - a.ageMs)
|
|
242
|
+
.slice(0, 8)
|
|
243
|
+
.map(x => `${age(x.ageMs)} ${x.fp.split(/[/\\]/).slice(-2).join('/')}`),
|
|
244
|
+
recentCount: tempScripts.filter(x => now - x.ageMs < HOUR).length,
|
|
245
|
+
};
|
|
246
|
+
return junk;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 6) Stray scripts in repo roots
|
|
250
|
+
function detectStrayRepoScripts() {
|
|
251
|
+
const strayRepo = [];
|
|
252
|
+
for (const repoPath of repos) {
|
|
253
|
+
if (!fs.existsSync(repoPath)) continue;
|
|
254
|
+
const recent = sh('git log --since="7 days ago" --name-only --pretty=format: --diff-filter=A', repoPath)
|
|
255
|
+
.split('\n')
|
|
256
|
+
.map(s => s.trim())
|
|
257
|
+
.filter(Boolean);
|
|
258
|
+
for (const f of new Set(recent)) {
|
|
259
|
+
if (/^[^/\\]+\.(py|mjs|js|sql)$/.test(f)) {
|
|
260
|
+
strayRepo.push(`${path.basename(repoPath)}: ${f}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return strayRepo;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 7) Security alert review
|
|
268
|
+
function checkSecurityAlerts() {
|
|
269
|
+
const alertLog = path.join(STATE_DIR, 'SECURITY-ALERTS.log');
|
|
270
|
+
const st = stat(alertLog);
|
|
271
|
+
if (!st) return { count: 0, highMedCount: 0 };
|
|
272
|
+
try {
|
|
273
|
+
const content = fs.readFileSync(alertLog, 'utf8');
|
|
274
|
+
const lines = content.split('\n');
|
|
275
|
+
const highMedCount = lines.filter(l => /HIGH|MED/.test(l) && !l.includes('SUPPRESSED-FP')).length;
|
|
276
|
+
return { count: lines.filter(l => l.trim()).length, highMedCount };
|
|
277
|
+
} catch {
|
|
278
|
+
return { count: 0, highMedCount: 0 };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 8) Respawn watch (Rule 6 retry cap)
|
|
283
|
+
function detectRespawnWatch() {
|
|
284
|
+
const respawnWatch = [];
|
|
285
|
+
const ledgerPath = path.join(BRAIN_ROOT, 'FLEET-LEDGER.md');
|
|
286
|
+
if (!fs.existsSync(ledgerPath)) return respawnWatch;
|
|
287
|
+
try {
|
|
288
|
+
const content = fs.readFileSync(ledgerPath, 'utf8');
|
|
289
|
+
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('|'));
|
|
290
|
+
const windowSize = 50;
|
|
291
|
+
const recentStart = Math.max(0, lines.length - windowSize);
|
|
292
|
+
const signatures = {};
|
|
293
|
+
const normalize = (desc) => (desc || '').substring(0, 40).toLowerCase().trim();
|
|
294
|
+
for (let i = recentStart; i < lines.length; i++) {
|
|
295
|
+
const line = lines[i];
|
|
296
|
+
const parts = line.split('|').map(s => s.trim());
|
|
297
|
+
if (parts.length >= 4) {
|
|
298
|
+
const description = parts[3];
|
|
299
|
+
const sig = normalize(description);
|
|
300
|
+
if (sig) signatures[sig] = (signatures[sig] || 0) + 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const [sig, count] of Object.entries(signatures)) {
|
|
304
|
+
if (count > 3) {
|
|
305
|
+
respawnWatch.push({
|
|
306
|
+
signature: sig,
|
|
307
|
+
count,
|
|
308
|
+
warning: `RESPAWN CAP: '${sig}' dispatched ${count} times — investigate hang or mark BLOCKED`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
// fail-open on error
|
|
314
|
+
}
|
|
315
|
+
return respawnWatch;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 9) Cost cadence tracking
|
|
319
|
+
function trackCostCadence() {
|
|
320
|
+
const prevStateFile = path.join(MON, '.signal-state.json');
|
|
321
|
+
let prevState = {};
|
|
322
|
+
try {
|
|
323
|
+
prevState = JSON.parse(fs.readFileSync(prevStateFile, 'utf8'));
|
|
324
|
+
} catch {
|
|
325
|
+
// no prev state
|
|
326
|
+
}
|
|
327
|
+
const cycleCount = (prevState.cycleCount || 0) + 1;
|
|
328
|
+
let costTick = null;
|
|
329
|
+
if (cycleCount % 3 === 0) {
|
|
330
|
+
costTick = {
|
|
331
|
+
cycle: cycleCount,
|
|
332
|
+
ts: new Date(now).toISOString(),
|
|
333
|
+
summary: 'Cost cycle tick recorded',
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return { cycleCount, costTick };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 10) Unreviewed prompts count
|
|
340
|
+
function checkUnreviewedPrompts() {
|
|
341
|
+
const seenFile = path.join(MON, '.fleet-prompts-seen.json');
|
|
342
|
+
let prevSeen = {};
|
|
343
|
+
try {
|
|
344
|
+
prevSeen = JSON.parse(fs.readFileSync(seenFile, 'utf8'));
|
|
345
|
+
} catch {
|
|
346
|
+
// no prev state
|
|
347
|
+
}
|
|
348
|
+
// Without fleet_prompt_extractor.py available, emit 0
|
|
349
|
+
// In production, invoke spawnSync('python', [...fleet_prompt_extractor.py...])
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// === Main ===
|
|
354
|
+
const staleLoops = checkHeartbeats();
|
|
355
|
+
const gitState = checkGitState();
|
|
356
|
+
const memory = checkMemoryFreshness();
|
|
357
|
+
const logFiles = checkLogFiles();
|
|
358
|
+
const junk = detectJunkScripts();
|
|
359
|
+
const strayRepo = detectStrayRepoScripts();
|
|
360
|
+
const alerts = checkSecurityAlerts();
|
|
361
|
+
const respawnWatch = detectRespawnWatch();
|
|
362
|
+
const { cycleCount, costTick } = trackCostCadence();
|
|
363
|
+
const unreviewedPrompts = checkUnreviewedPrompts();
|
|
364
|
+
|
|
365
|
+
const signals = {
|
|
366
|
+
timestamp: new Date(now).toISOString(),
|
|
367
|
+
cycleCount,
|
|
368
|
+
heartbeats: { staleCount: staleLoops.length, details: staleLoops },
|
|
369
|
+
git: gitState,
|
|
370
|
+
memory,
|
|
371
|
+
logs: logFiles,
|
|
372
|
+
junk,
|
|
373
|
+
strayRepo,
|
|
374
|
+
alerts,
|
|
375
|
+
respawnWatch,
|
|
376
|
+
costTick,
|
|
377
|
+
unreviewedPrompts,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const brief = [];
|
|
381
|
+
brief.push(`# Aesop Monitor Brief — ${signals.timestamp}`);
|
|
382
|
+
brief.push('');
|
|
383
|
+
brief.push('## Heartbeat check');
|
|
384
|
+
if (staleLoops.length === 0) {
|
|
385
|
+
brief.push('✓ All heartbeats fresh (watchdog 300s, monitor 3600s, others 1800s).');
|
|
386
|
+
} else {
|
|
387
|
+
brief.push(`✗ **${staleLoops.length} stale loop(s)** detected:`);
|
|
388
|
+
for (const sl of staleLoops) {
|
|
389
|
+
brief.push(` - ${sl.name}: ${age(sl.ageMs)} (threshold: ${age(sl.threshold)})`);
|
|
390
|
+
}
|
|
89
391
|
}
|
|
392
|
+
brief.push('');
|
|
90
393
|
|
|
91
|
-
brief.push('##
|
|
92
|
-
if (
|
|
93
|
-
brief.push('No
|
|
394
|
+
brief.push('## Git state');
|
|
395
|
+
if (gitState.length === 0) {
|
|
396
|
+
brief.push('No repos configured.');
|
|
94
397
|
} else {
|
|
95
|
-
for (const
|
|
96
|
-
const status =
|
|
97
|
-
brief.push(`- ${status} ${
|
|
398
|
+
for (const g of gitState) {
|
|
399
|
+
const status = g.dirty === 0 && g.ahead === '0' ? '✓' : '⚠';
|
|
400
|
+
brief.push(`- ${status} **${g.repo}** [${g.branch}] — dirty: ${g.dirty}, ahead: ${g.ahead}`);
|
|
401
|
+
if (g.lastCommit) brief.push(` ${g.lastCommit}`);
|
|
98
402
|
}
|
|
99
403
|
}
|
|
100
404
|
brief.push('');
|
|
101
405
|
|
|
102
|
-
|
|
103
|
-
brief.push(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
406
|
+
brief.push('## Memory');
|
|
407
|
+
brief.push(`- ${memory.count} memory file(s)`);
|
|
408
|
+
if (memory.staleCount > 0) {
|
|
409
|
+
brief.push(` ⚠ **${memory.staleCount} stale** (>30d): ${memory.staleMemories.join(', ')}`);
|
|
410
|
+
}
|
|
411
|
+
brief.push('');
|
|
108
412
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
413
|
+
brief.push('## Log files');
|
|
414
|
+
const needsRotation = logFiles.filter(l => l.needsRotation);
|
|
415
|
+
if (needsRotation.length === 0) {
|
|
416
|
+
brief.push('✓ All logs within thresholds.');
|
|
417
|
+
} else {
|
|
418
|
+
brief.push(`⚠ **${needsRotation.length} log(s) need rotation:**`);
|
|
419
|
+
for (const l of needsRotation) {
|
|
420
|
+
brief.push(` - ${l.name}: ${l.lineCount} lines, ${l.sizeKb}kb`);
|
|
115
421
|
}
|
|
116
422
|
}
|
|
117
423
|
brief.push('');
|
|
118
424
|
|
|
119
|
-
|
|
120
|
-
brief.push(
|
|
121
|
-
brief.push(
|
|
122
|
-
|
|
123
|
-
brief.push('
|
|
124
|
-
|
|
125
|
-
brief.push(
|
|
425
|
+
brief.push('## Junk-script sprawl (temp/scratch)');
|
|
426
|
+
brief.push(`- ${junk.total} total scripts, ${(junk.bytes / 1024).toFixed(0)}kb`);
|
|
427
|
+
brief.push(` Quarantinable (>24h, not live): ${junk.quarantinable}`);
|
|
428
|
+
if (junk.oldest.length > 0) {
|
|
429
|
+
brief.push(' Oldest:');
|
|
430
|
+
for (const o of junk.oldest) {
|
|
431
|
+
brief.push(` ${o}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (strayRepo.length > 0) {
|
|
435
|
+
brief.push('');
|
|
436
|
+
brief.push('## Stray repo scripts (7d)');
|
|
437
|
+
for (const s of strayRepo) {
|
|
438
|
+
brief.push(`- ${s}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
126
441
|
brief.push('');
|
|
127
442
|
|
|
443
|
+
brief.push('## Security');
|
|
444
|
+
brief.push(`- Alert log: ${alerts.count} entries, ${alerts.highMedCount} HIGH/MED`);
|
|
445
|
+
brief.push('');
|
|
446
|
+
|
|
447
|
+
brief.push('## Respawn watch (Rule 6 retry cap)');
|
|
448
|
+
if (respawnWatch.length === 0) {
|
|
449
|
+
brief.push('✓ No retry-cap breaches (all signatures ≤3 occurrences).');
|
|
450
|
+
} else {
|
|
451
|
+
brief.push(`⚠ **${respawnWatch.length} signature(s) exceeded 3-attempt limit:**`);
|
|
452
|
+
for (const rw of respawnWatch) {
|
|
453
|
+
brief.push(` - ${rw.warning}`);
|
|
454
|
+
}
|
|
455
|
+
brief.push(' (Note: distinguish legitimate fan-outs from identical retries; manual review recommended.)');
|
|
456
|
+
}
|
|
457
|
+
brief.push('');
|
|
458
|
+
|
|
459
|
+
brief.push('## Cost tracking');
|
|
460
|
+
brief.push(`- Cycle: ${cycleCount}${costTick ? ' — tick recorded' : ''}`);
|
|
461
|
+
if (costTick) {
|
|
462
|
+
brief.push(` ${costTick.ts}`);
|
|
463
|
+
}
|
|
464
|
+
brief.push('');
|
|
465
|
+
|
|
466
|
+
brief.push('## Unreviewed prompts');
|
|
467
|
+
brief.push(`- ${unreviewedPrompts} new prompt(s) awaiting semantic review`);
|
|
468
|
+
brief.push('');
|
|
469
|
+
|
|
470
|
+
brief.push('_Refinement points → act per CHARTER.md (AUTO safe, PROPOSE rule changes). Goal is fixed._');
|
|
471
|
+
|
|
128
472
|
// Write outputs
|
|
129
473
|
try {
|
|
130
474
|
fs.mkdirSync(MON, { recursive: true });
|
|
131
475
|
fs.writeFileSync(path.join(MON, 'BRIEF.md'), brief.join('\n'), 'utf8');
|
|
132
476
|
fs.writeFileSync(path.join(MON, 'SIGNALS.json'), JSON.stringify(signals, null, 2), 'utf8');
|
|
133
|
-
|
|
477
|
+
fs.writeFileSync(path.join(MON, '.monitor-heartbeat'), String(Math.floor(now / 1000)), 'utf8');
|
|
478
|
+
const summaryLine = `stale-loops: ${staleLoops.length}, repos-dirty: ${gitState.filter(g => g.dirty > 0).length}, stale-mem: ${memory.staleCount}, logs-need-rotation: ${needsRotation.length}, junk-quarantinable: ${junk.quarantinable}, stray-repo-scripts: ${strayRepo.length}, alerts-high-med: ${alerts.highMedCount}, respawn-watch: ${respawnWatch.length}, cycle: ${cycleCount}`;
|
|
479
|
+
console.log(summaryLine);
|
|
134
480
|
} catch (e) {
|
|
135
481
|
console.error('Failed to write signals:', e.message);
|
|
136
482
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matt82198/aesop",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.2",
|
|
4
4
|
"description": "Multi-agent orchestration for AI coding agents, designed to survive failure — a plain-file brain, git as the only durable layer, cheap subagent fleets, and guardrails enforced in code.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aesop": "bin/cli.js"
|
|
@@ -45,6 +45,5 @@
|
|
|
45
45
|
"publishConfig": {
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
|
-
"scripts": {
|
|
49
|
-
}
|
|
48
|
+
"scripts": {}
|
|
50
49
|
}
|