@matt82198/aesop 0.2.0 → 0.3.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +50 -260
  3. package/bin/cli.js +7 -3
  4. package/docs/HOOK-INSTALL.md +15 -56
  5. package/docs/INSTALL.md +4 -3
  6. package/docs/PORTING.md +166 -0
  7. package/docs/TEAM-STATE.md +16 -184
  8. package/docs/reproduce.md +33 -3
  9. package/driver/CLAUDE.md +50 -48
  10. package/driver/codex_driver.py +59 -12
  11. package/driver/openai_transport.py +33 -0
  12. package/driver/wave_bridge.py +9 -1
  13. package/driver/wave_loop.py +437 -70
  14. package/driver/wave_scheduler.py +890 -0
  15. package/hooks/pre-push-policy.sh +22 -5
  16. package/monitor/collect-signals.mjs +69 -0
  17. package/package.json +4 -4
  18. package/state_store/__init__.py +2 -1
  19. package/state_store/projections.py +63 -0
  20. package/state_store/read_api.py +156 -0
  21. package/state_store/write_api.py +462 -0
  22. package/tools/cost_ceiling.py +106 -15
  23. package/tools/cost_projection.py +559 -0
  24. package/tools/crossos_drift.py +394 -0
  25. package/tools/eod_sweep.py +137 -33
  26. package/tools/mutation_test.py +130 -8
  27. package/tools/proposals.mjs +47 -2
  28. package/tools/reproduce.js +405 -0
  29. package/tools/secret_scan.py +73 -18
  30. package/tools/stall_check.py +247 -16
  31. package/tools/stateapi_lint.py +325 -0
  32. package/tools/test_battery.py +173 -0
  33. package/tools/verify_cost_panel.py +345 -0
  34. package/tools/wave_preflight.py +296 -29
  35. package/tools/wave_templates.py +170 -45
  36. package/ui/collectors.py +81 -0
  37. package/ui/handler.py +230 -85
  38. package/ui/sse.py +3 -3
  39. package/ui/wave_telemetry.py +24 -18
  40. package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
  41. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  42. package/ui/web/dist/index.html +2 -2
  43. package/docs/QUICKSTART.md +0 -80
  44. package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
@@ -252,7 +252,7 @@ check_branch_policy() {
252
252
  # Finding 3: Handle tty mode and final line without trailing newline
253
253
  if [ -t 0 ]; then
254
254
  # Running interactively on a tty; skip stdin processing with note
255
- # but still check current branch as fallback
255
+ # but still check current branch as fallback. Note: main() blocks tty before this can matter.
256
256
  :
257
257
  else
258
258
  # Not a tty; read stdin normally
@@ -323,7 +323,9 @@ get_commit_range() {
323
323
  local saw_any_tuple=0
324
324
 
325
325
  if [ -t 0 ]; then
326
- # Running interactively on a tty; no stdin to parse
326
+ # Running interactively on a tty; no stdin to parse → fail-closed (direct hook invocation)
327
+ # main() blocks tty before this is reached via the normal flow; nearly dead code but tests exercise it
328
+ printf 'Error: No stdin on tty; cannot parse pre-push ref tuples (interactive hook invocation)\n' >&2
327
329
  return 1
328
330
  fi
329
331
 
@@ -623,6 +625,13 @@ run_test_mode() {
623
625
  (
624
626
  export AESOP_ROOT="$tmpdir/aesop"
625
627
  mkdir -p "$AESOP_ROOT/state"
628
+ # SCOPED (identity-polluter class): quoted-name check runs inside an
629
+ # isolated fixture repo; must NEVER touch the live repo config.
630
+ SELFTEST_REPO="$tmpdir/selftest_identity_repo"
631
+ mkdir -p "$SELFTEST_REPO"
632
+ cd "$SELFTEST_REPO" || exit 1
633
+ git init -q
634
+ git config user.email "test@example.com"
626
635
  git config user.name 'John "Jack" Doe'
627
636
  log_block "reason_with_backslash\\test"
628
637
 
@@ -1038,15 +1047,23 @@ main() {
1038
1047
  exit $?
1039
1048
  fi
1040
1049
 
1050
+ # Option B: Fail-closed TTY semantics — block interactive invocation before stdin capture.
1051
+ # git pre-push ALWAYS pipes stdin; tty means human ran hook directly (not via git push).
1052
+ # Refusing to skip security checks in interactive mode closes the window where empty stdin
1053
+ # could be misinterpreted as a legitimate up-to-date push.
1054
+ if [ -t 0 ]; then
1055
+ printf 'Error: interactive invocation: this hook runs under git push; refusing to skip security checks (fail-closed)\n' >&2
1056
+ log_block "interactive_invocation_blocked"
1057
+ exit 1
1058
+ fi
1059
+
1041
1060
  # git pre-push provides ref info on stdin, and BOTH check_branch_policy and
1042
1061
  # check_secret_scan (via get_commit_range) need to read every ref tuple.
1043
1062
  # Capture the real pipe ONCE here and hand each consumer its own here-string
1044
1063
  # copy -- reading a pipe twice on the same fd starves the second reader
1045
1064
  # (once check_branch_policy drains it, get_commit_range would see nothing
1046
1065
  # but EOF and fail-closed on every push). A here-string preserves each
1047
- # function's existing tty-vs-pipe read semantics unchanged (an interactive
1048
- # tty yields no captured content, which both functions already treat the
1049
- # same way as "no ref tuples" via their existing fallback/fail-closed paths).
1066
+ # function's existing tty-vs-pipe read semantics unchanged.
1050
1067
  local prepush_stdin=""
1051
1068
  if [ ! -t 0 ]; then
1052
1069
  prepush_stdin=$(cat)
@@ -613,6 +613,58 @@ function detectIsolationViolations() {
613
613
  return { violations, count: violations.length };
614
614
  }
615
615
 
616
+ // 12) Agent stall detection
617
+ // Calls tools/stall_check.py --json with bounded timeout; fails gracefully with NOT-AVAILABLE signal
618
+ function checkAgentStalls() {
619
+ let stallCheckPy = path.join(path.dirname(MON), 'tools', 'stall_check.py');
620
+
621
+ // Fallback: look in SCRIPTS_ROOT if not found in tools
622
+ if (!fs.existsSync(stallCheckPy)) {
623
+ stallCheckPy = path.join(SCRIPTS_ROOT, 'stall_check.py');
624
+ }
625
+
626
+ // Fallback: look in the actual aesop source directory (for tests/CI environments)
627
+ if (!fs.existsSync(stallCheckPy)) {
628
+ const realMonitorCharter = path.join(__dirname, 'CHARTER.md');
629
+ if (fs.existsSync(realMonitorCharter)) {
630
+ stallCheckPy = path.join(__dirname, '..', 'tools', 'stall_check.py');
631
+ }
632
+ }
633
+
634
+ if (!fs.existsSync(stallCheckPy)) {
635
+ // stall_check.py not available; return NOT-AVAILABLE signal
636
+ return { available: false, count: 0, summary: 'Tool not found', stalls: [] };
637
+ }
638
+
639
+ try {
640
+ // Invoke stall_check.py with 5-second timeout (bounded, fail-open)
641
+ const result = spawnSync('python', [stallCheckPy, '--json'], {
642
+ encoding: 'utf8',
643
+ timeout: 5000,
644
+ stdio: ['ignore', 'pipe', 'pipe'],
645
+ });
646
+
647
+ if (result.status !== 0 || result.error) {
648
+ // Tool execution failed; return NOT-AVAILABLE signal
649
+ return { available: false, count: 0, summary: 'Tool execution failed', stalls: [] };
650
+ }
651
+
652
+ const stalls = JSON.parse(result.stdout || '[]');
653
+ const stalledCount = stalls.filter(s => s.verdict && ['stale', 'dead'].includes(s.verdict)).length;
654
+
655
+ return {
656
+ available: true,
657
+ count: stalledCount,
658
+ total: stalls.length,
659
+ summary: stalledCount > 0 ? `${stalledCount} agent(s) stalled` : 'No stalls detected',
660
+ stalls: stalls,
661
+ };
662
+ } catch (e) {
663
+ // Parse error or other exception; return NOT-AVAILABLE signal
664
+ return { available: false, count: 0, summary: `Error: ${e.message}`, stalls: [] };
665
+ }
666
+ }
667
+
616
668
  // === AUTO Actions ===
617
669
  // Log rotation: invoke rotate_logs.py if available and log needs rotation
618
670
  function performAutoLogRotation(logFiles, actionsLogPath) {
@@ -839,6 +891,7 @@ const gitState = checkGitState();
839
891
  const memory = checkMemoryFreshness();
840
892
  const logFiles = checkLogFiles(securityAlertsContent);
841
893
  const isolationViolations = detectIsolationViolations();
894
+ const agentStalls = checkAgentStalls();
842
895
 
843
896
  // Extended signal checks (5, 6, 8, 10) — skipped if extended_signals is OFF
844
897
  const junk = extendedSignals ? detectJunkScripts() : { skipped: true };
@@ -888,6 +941,7 @@ const signals = {
888
941
  costTick,
889
942
  unreviewedPrompts,
890
943
  isolationViolations,
944
+ agentStalls,
891
945
  };
892
946
 
893
947
  const brief = [];
@@ -946,6 +1000,21 @@ if (isolationViolations.count === 0) {
946
1000
  }
947
1001
  brief.push('');
948
1002
 
1003
+ brief.push('## Agent stalls');
1004
+ if (!agentStalls.available) {
1005
+ brief.push(`NOT-AVAILABLE: ${agentStalls.summary}`);
1006
+ } else if (agentStalls.count === 0) {
1007
+ brief.push('✓ No agent stalls detected.');
1008
+ } else {
1009
+ brief.push(`🚨 **${agentStalls.count} agent(s) stalled** (${agentStalls.total} total scanned):`);
1010
+ for (const stall of agentStalls.stalls) {
1011
+ if (['stale', 'dead'].includes(stall.verdict)) {
1012
+ brief.push(` - ${stall.agent}: ${stall.verdict} (${stall.mtime_age_s}s old)`);
1013
+ }
1014
+ }
1015
+ }
1016
+ brief.push('');
1017
+
949
1018
  // Extended signals section (if disabled, just note they're off; if enabled, show details)
950
1019
  if (extendedSignals) {
951
1020
  brief.push('## Junk-script sprawl (temp/scratch)');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@matt82198/aesop",
3
- "version": "0.2.0",
4
- "description": "Multi-agent orchestration for AI coding agents, designed to survive failure \u2014 a plain-file brain, git as the only durable layer, cheap subagent fleets, and guardrails enforced in code.",
3
+ "version": "0.3.1",
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"
7
7
  },
@@ -70,8 +70,8 @@
70
70
  "scripts": {
71
71
  "test": "node --test --test-force-exit --test-timeout=60000 tests/*.test.mjs",
72
72
  "test:node": "node --test --test-force-exit --test-timeout=60000 tests/*.test.mjs",
73
- "test:sh": "bash tests/test_pre_push_policy.sh && bash tests/test-run-watchdog.sh && bash tests/test-run-watchdog-halt.sh && bash tests/test-run-watchdog-lockguard.sh && bash tests/backup-fleet.test.sh && bash tests/test_reconstitute.sh && bash tests/test_reconstitute_fixes.sh && bash tests/test_agent_forensics.sh && bash tests/test-selfheal.sh && bash hooks/pre-push-policy.sh --test && bash tools/reconstitute.sh --test",
73
+ "test:sh": "bash tests/test_pre_push_policy.sh && bash tests/test-run-watchdog.sh && bash tests/test-run-watchdog-halt.sh && bash tests/test-run-watchdog-lockguard.sh && bash tests/backup-fleet.test.sh && bash tests/test_reconstitute.sh && bash tests/test_reconstitute_fixes.sh && bash tests/test_agent_forensics.sh && bash tests/test-selfheal.sh && bash hooks/pre-push-policy.sh --test",
74
74
  "test:py": "python -m unittest discover -s tests",
75
75
  "test:all": "npm run test:node && npm run test:sh && npm run test:py"
76
76
  }
77
- }
77
+ }
@@ -12,7 +12,7 @@ in state_store/CLAUDE.md.
12
12
  from .api import StateAPI
13
13
  from .export import export_tracker
14
14
  from .ingest import ingest_tracker_json
15
- from .projections import project_tracker
15
+ from .projections import project_tracker, project_agent_lifecycle
16
16
  from .store import EventStore, ConcurrencyConflict
17
17
 
18
18
  __all__ = [
@@ -20,6 +20,7 @@ __all__ = [
20
20
  "StateAPI",
21
21
  "ConcurrencyConflict",
22
22
  "project_tracker",
23
+ "project_agent_lifecycle",
23
24
  "export_tracker",
24
25
  "ingest_tracker_json",
25
26
  ]
@@ -124,3 +124,66 @@ def save_snapshot(store, stream: str, event_version: int, projection: dict) -> N
124
124
  projection: the materialized state dict (e.g. full tracker projection)
125
125
  """
126
126
  store.save_snapshot(stream, event_version, projection)
127
+
128
+
129
+ AGENT_LIFECYCLE_VERSION = 1
130
+
131
+
132
+ def project_agent_lifecycle(events: list) -> dict:
133
+ """Project current agent lifecycle state from agent_* events.
134
+
135
+ Folds agent lifecycle events into a map of agent_id -> {state, transitions, last_activity}.
136
+
137
+ Event types:
138
+ - agent_dispatched: payload {"agent_id", "timestamp"}; marks dispatch start
139
+ - agent_working: payload {"agent_id", "timestamp"}; marks work in progress
140
+ - agent_done: payload {"agent_id", "timestamp"}; marks completion
141
+ - agent_stalled: payload {"agent_id", "timestamp"}; marks stall/error
142
+
143
+ Returns a dict: {"version": 1, "agents": [{"id", "state", "transitions", "last_activity"}, ...]}
144
+ where transitions is a list of {state, at (ISO 8601 timestamp)} in chronological order.
145
+ """
146
+ agents = {}
147
+
148
+ for ev in events:
149
+ etype = ev.get("type")
150
+ payload = ev.get("payload") or {}
151
+
152
+ if etype in ("agent_dispatched", "agent_working", "agent_done", "agent_stalled"):
153
+ agent_id = payload.get("agent_id")
154
+ timestamp = payload.get("timestamp")
155
+
156
+ if agent_id is None:
157
+ continue
158
+
159
+ # Initialize agent if first time seeing it
160
+ if agent_id not in agents:
161
+ agents[agent_id] = {
162
+ "id": agent_id,
163
+ "state": None,
164
+ "transitions": [],
165
+ "last_activity": None,
166
+ }
167
+
168
+ # Map event type to state
169
+ state_map = {
170
+ "agent_dispatched": "dispatch",
171
+ "agent_working": "working",
172
+ "agent_done": "done",
173
+ "agent_stalled": "stalled",
174
+ }
175
+ new_state = state_map.get(etype)
176
+
177
+ # Only append transition if state actually changed
178
+ if new_state and new_state != agents[agent_id]["state"]:
179
+ agents[agent_id]["state"] = new_state
180
+ if timestamp:
181
+ agents[agent_id]["transitions"].append({
182
+ "state": new_state,
183
+ "at": timestamp,
184
+ })
185
+ agents[agent_id]["last_activity"] = timestamp
186
+
187
+ # Return as versioned list (ordered by agent_id for determinism)
188
+ agent_list = sorted(agents.values(), key=lambda a: a["id"])
189
+ return {"version": AGENT_LIFECYCLE_VERSION, "agents": agent_list}
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ state_store.read_api — Typed read-only facade for state surfaces.
4
+
5
+ Consolidates read patterns for: tracker snapshot, orchestrator-status,
6
+ heartbeat freshness, and ledger rows. This facade allows the underlying
7
+ state representation to change (git → SQLite → Postgres) without altering
8
+ caller code.
9
+
10
+ Callers use:
11
+ api = ReadAPI(state_dir)
12
+ tracker = api.read_tracker_snapshot()
13
+ status = api.read_orchestrator_status()
14
+ is_fresh = api.is_orchestrator_status_fresh(threshold_s=300)
15
+ is_fresh = api.check_heartbeat_fresh(".watchdog-heartbeat", 200)
16
+ rows = api.read_ledger_rows()
17
+ """
18
+ import json
19
+ import sys
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ # Ensure tools module is importable (sys.path fix for bootstrapping)
24
+ repo_root = Path(__file__).parent.parent
25
+ if str(repo_root) not in sys.path:
26
+ sys.path.insert(0, str(repo_root))
27
+
28
+ from tools.common import check_heartbeat_staleness
29
+ from tools.fleet_ledger import parse_ledger_rows as parse_ledger_rows_impl
30
+
31
+
32
+ class ReadAPI:
33
+ """Read-only facade over state surfaces (tracker, orchestrator-status, heartbeats, ledger).
34
+
35
+ Designed to be swappable: backends can change (git → SQLite → Postgres) without
36
+ altering call sites. Current implementation reads from filesystem; future may
37
+ read from SQLite projections or API.
38
+ """
39
+
40
+ def __init__(self, state_dir):
41
+ """Initialize the read API with a state directory.
42
+
43
+ Args:
44
+ state_dir: Path to the state directory (e.g., "state" or "/absolute/path/state")
45
+ """
46
+ self.state_dir = Path(state_dir)
47
+ self.state_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ def read_tracker_snapshot(self):
50
+ """Read the current tracker snapshot.
51
+
52
+ Returns the tracker.json file, or an empty dict if missing/unreadable.
53
+ In the future, this may read from the SQLite projection instead.
54
+
55
+ Returns:
56
+ dict: Tracker snapshot (items, metadata, etc.) or {} if unavailable.
57
+ """
58
+ tracker_file = self.state_dir / "tracker.json"
59
+ if not tracker_file.exists():
60
+ return {}
61
+
62
+ try:
63
+ content = tracker_file.read_text(encoding="utf-8")
64
+ return json.loads(content)
65
+ except Exception:
66
+ # Malformed JSON or read error: return empty dict (fail-open)
67
+ return {}
68
+
69
+ def read_orchestrator_status(self):
70
+ """Read orchestrator-status.json if present.
71
+
72
+ Returns:
73
+ dict with "updated_at" (required) and optional "phase", "activity" fields,
74
+ or None if file missing/unreadable.
75
+ """
76
+ status_file = self.state_dir / "orchestrator-status.json"
77
+ if not status_file.exists():
78
+ return None
79
+
80
+ try:
81
+ content = status_file.read_text(encoding="utf-8")
82
+ data = json.loads(content)
83
+ # Only require updated_at field for freshness checks;
84
+ # phase is optional (may not be set when status.json created before phase exists)
85
+ # Don't validate here—let callers decide what fields are required for their use case
86
+ return data
87
+ except Exception:
88
+ return None
89
+
90
+ def is_orchestrator_status_fresh(self, threshold_s=300):
91
+ """Check if orchestrator-status.json exists and is fresh.
92
+
93
+ Reads updated_at timestamp and compares to current time.
94
+ Returns False if file missing, unparseable, or stale.
95
+
96
+ Args:
97
+ threshold_s: Staleness threshold in seconds (default: 300s)
98
+
99
+ Returns:
100
+ bool: True if file is fresh, False if stale/missing/malformed.
101
+ """
102
+ status = self.read_orchestrator_status()
103
+ if status is None:
104
+ return False
105
+
106
+ try:
107
+ updated_at_str = status.get("updated_at")
108
+ if not updated_at_str:
109
+ return False
110
+
111
+ # Parse ISO format timestamp (handle both "Z" and "+00:00")
112
+ normalized = updated_at_str.replace("Z", "+00:00")
113
+ updated_at = datetime.fromisoformat(normalized)
114
+
115
+ now = datetime.now(timezone.utc)
116
+ age = (now - updated_at).total_seconds()
117
+
118
+ # Treat future-dated timestamps as stale (fail-closed)
119
+ if age < -120: # More than 2min in future = clock skew, treat as stale
120
+ return False
121
+
122
+ # Clamp small negative ages to 0
123
+ age = max(0, age)
124
+
125
+ return age < threshold_s
126
+ except Exception:
127
+ return False
128
+
129
+ def check_heartbeat_fresh(self, heartbeat_filename, threshold_s=200):
130
+ """Check if a heartbeat file is fresh.
131
+
132
+ Delegates to tools.common.check_heartbeat_staleness (single source of truth).
133
+
134
+ Args:
135
+ heartbeat_filename: Filename in state_dir (e.g., ".watchdog-heartbeat")
136
+ threshold_s: Staleness threshold in seconds (default: 200s)
137
+
138
+ Returns:
139
+ bool: True if file exists and is fresh, False otherwise.
140
+ """
141
+ hb_file = self.state_dir / heartbeat_filename
142
+ # Use the shared implementation from common.py; invert staleness to freshness
143
+ is_stale, _, _ = check_heartbeat_staleness(hb_file, threshold_s)
144
+ return not is_stale
145
+
146
+ def read_ledger_rows(self):
147
+ """Read OUTCOMES-LEDGER.md and parse rows.
148
+
149
+ Delegates to tools.fleet_ledger.parse_ledger_rows() (single source of truth).
150
+ Returns empty list if file missing or unparseable.
151
+
152
+ Returns:
153
+ list: List of row dicts parsed from the ledger table, or [].
154
+ """
155
+ # Use the shared implementation from fleet_ledger.py
156
+ return parse_ledger_rows_impl()