@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
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ tools.stateapi_lint — Scanner for direct state file opens outside the API.
4
+
5
+ Detects violations of the "reads go through state_store.read_api" rule by scanning
6
+ ui/ and tools/ for direct opens of state files (tracker.json, orchestrator-status.json,
7
+ heartbeat files, OUTCOMES-LEDGER.md) outside the read_api module.
8
+
9
+ Writers (state_store/export.py, state_store/ingest.py, etc.) are allowlisted.
10
+
11
+ RATCHET MODE (hardening):
12
+ Baseline is keyed by file + pattern-id (NOT line numbers).
13
+ Without --update-baseline flag:
14
+ - FAILS if baseline contains entries absent from current scan (stale baseline)
15
+ - FAILS if current violations missing from baseline (new violations)
16
+ - PASS only if: current violations exactly match baseline entries
17
+ With --update-baseline flag:
18
+ - Allows regenerating the baseline (sets current as new baseline)
19
+
20
+ CI MUST NEVER pass --update-baseline (hand-edits of baseline detected & fail).
21
+
22
+ Exit codes:
23
+ 0 = all checks pass
24
+ 1 = violations detected or baseline mismatch
25
+ 2 = error
26
+
27
+ Usage:
28
+ python tools/stateapi_lint.py [--root REPO_ROOT] [--json] [--update-baseline]
29
+
30
+ Options:
31
+ --root REPO_ROOT: Repository root (default: cwd)
32
+ --json: Output JSON instead of text
33
+ --update-baseline: Regenerate baseline from current violations (CI must never use)
34
+ """
35
+ import json
36
+ import re
37
+ import sys
38
+ from pathlib import Path
39
+
40
+
41
+ # State files that should only be read via the API
42
+ STATE_FILES_TO_PROTECT = [
43
+ "tracker.json",
44
+ "orchestrator-status.json",
45
+ "OUTCOMES-LEDGER.md",
46
+ ".watchdog-heartbeat",
47
+ ".monitor-heartbeat",
48
+ ".orchestrator-heartbeat",
49
+ ]
50
+
51
+ # File patterns that are WRITERS and allowed to access state files directly
52
+ WRITER_ALLOWLIST = [
53
+ "state_store/export.py",
54
+ "state_store/ingest.py",
55
+ "state_store/read_api.py", # The read API facade itself (reads the state files)
56
+ "state_store/write_api.py", # The write API facade (reads/writes the projection atomically)
57
+ "ui/collectors.py", # Some readers also export/flush
58
+ "tools/cost.py", # Parses ledger
59
+ ]
60
+
61
+ # Directories to scan for violations
62
+ SCAN_DIRS = ["ui", "tools", "state_store"] # Include state_store to catch internal issues
63
+
64
+
65
+ def find_direct_opens(repo_root):
66
+ """Scan repo for direct opens of protected state files outside the API.
67
+
68
+ Returns violations keyed by file + pattern-id (not line numbers).
69
+ Pattern-id is the matched pattern index, making the key stable across line edits.
70
+
71
+ Args:
72
+ repo_root: Path to repository root
73
+
74
+ Returns:
75
+ list: List of violation keys (file@pattern-id format)
76
+ """
77
+ repo_root = Path(repo_root)
78
+ violations = []
79
+
80
+ # Pattern to detect file opens: Path(...).read_text(), open(...), json.load(open(...)), etc.
81
+ # Each pattern gets an ID for stable keying across line number changes
82
+ read_patterns = [
83
+ (r'["\']tracker\.json["\']', "tracker-json"),
84
+ (r'["\']orchestrator-status\.json["\']', "status-json"),
85
+ (r'["\']OUTCOMES-LEDGER\.md["\']', "ledger-md"),
86
+ (r'["\']\.watchdog-heartbeat["\']', "watchdog-hb"),
87
+ (r'["\']\.monitor-heartbeat["\']', "monitor-hb"),
88
+ (r'["\']\.orchestrator-heartbeat["\']', "orchestrator-hb"),
89
+ (r'state\s*/\s*tracker\.json', "state-tracker-json"),
90
+ (r'state\s*/\s*orchestrator-status', "state-status-json"),
91
+ (r'state\s*/\s*.*heartbeat', "state-heartbeat"),
92
+ ]
93
+
94
+ for scan_dir in SCAN_DIRS:
95
+ scan_path = repo_root / scan_dir
96
+ if not scan_path.exists():
97
+ continue
98
+
99
+ for py_file in scan_path.rglob("*.py"):
100
+ # Check if this file is in the allowlist
101
+ relative_path = py_file.relative_to(repo_root)
102
+ is_allowed = any(
103
+ str(relative_path).replace("\\", "/") == allow.replace("\\", "/")
104
+ for allow in WRITER_ALLOWLIST
105
+ )
106
+
107
+ if is_allowed:
108
+ continue
109
+
110
+ try:
111
+ content = py_file.read_text(encoding="utf-8", errors="replace")
112
+ except Exception:
113
+ continue
114
+
115
+ # Skip if file imports read_api (it's using the facade correctly)
116
+ if "from state_store.read_api import" in content or "import state_store.read_api" in content:
117
+ continue
118
+
119
+ # Scan for violations
120
+ for line_num, line in enumerate(content.split("\n"), 1):
121
+ for pattern, pattern_id in read_patterns:
122
+ if re.search(pattern, line):
123
+ # Additional filter: skip comment lines and string literals in docstrings
124
+ if line.strip().startswith("#"):
125
+ continue
126
+ if '"""' in line or "'''" in line:
127
+ continue
128
+
129
+ # Key is file@pattern-id (stable across line edits)
130
+ # Use posix separators for cross-platform consistency
131
+ violation_key = f"{relative_path.as_posix()}@{pattern_id}"
132
+ if violation_key not in violations:
133
+ violations.append(violation_key)
134
+ break # Only report once per line per file
135
+
136
+ return sorted(violations)
137
+
138
+
139
+ def save_baseline(baseline_file, data):
140
+ """Save violations baseline to JSON file.
141
+
142
+ Args:
143
+ baseline_file: Path to baseline file
144
+ data: dict with "violations" list
145
+ """
146
+ baseline_file = Path(baseline_file)
147
+ baseline_file.parent.mkdir(parents=True, exist_ok=True)
148
+ baseline_file.write_text(json.dumps(data, indent=2))
149
+
150
+
151
+ def load_baseline(baseline_file):
152
+ """Load violations baseline from JSON file.
153
+
154
+ Returns:
155
+ dict with "violations" list, or empty dict if file missing.
156
+ """
157
+ baseline_file = Path(baseline_file)
158
+ if not baseline_file.exists():
159
+ return {"violations": []}
160
+
161
+ try:
162
+ return json.loads(baseline_file.read_text())
163
+ except Exception:
164
+ return {"violations": []}
165
+
166
+
167
+ def normalize_key(key):
168
+ """Normalize violation keys to forward-slash separators for cross-platform consistency.
169
+
170
+ Converts backslash separators (legacy Windows baseline entries) to forward slashes.
171
+
172
+ Args:
173
+ key: Violation key string
174
+
175
+ Returns:
176
+ str: Key with forward-slash separators
177
+ """
178
+ return key.replace("\\", "/")
179
+
180
+
181
+ def check_ratchet(baseline_violations, current_violations):
182
+ """Check ratchet: baseline and current must match exactly.
183
+
184
+ Fails if:
185
+ 1. Baseline contains entries absent from current (stale baseline)
186
+ 2. Current violations missing from baseline (new violations)
187
+
188
+ Only passes if current violations exactly match baseline (bidirectional check).
189
+ Normalizes both sets to forward-slash separators for cross-platform consistency.
190
+
191
+ Args:
192
+ baseline_violations: list of violation keys from baseline
193
+ current_violations: list of violation keys from current scan
194
+
195
+ Returns:
196
+ tuple: (is_ok, stale_entries, new_violations)
197
+ is_ok (bool): True only if baseline == current
198
+ stale_entries (list): Entries in baseline not in current
199
+ new_violations (list): Entries in current not in baseline
200
+ """
201
+ # Normalize both to forward slashes for comparison
202
+ baseline_set = set(normalize_key(v) for v in baseline_violations)
203
+ current_set = set(normalize_key(v) for v in current_violations)
204
+
205
+ stale = list(baseline_set - current_set)
206
+ new = list(current_set - baseline_set)
207
+
208
+ is_ok = (len(stale) == 0 and len(new) == 0)
209
+ return is_ok, sorted(stale), sorted(new)
210
+
211
+
212
+ def main(argv=None):
213
+ """CLI entry point."""
214
+ argv = sys.argv[1:] if argv is None else argv
215
+
216
+ repo_root = None
217
+ output_format = "text"
218
+ baseline_file = None
219
+ update_baseline = False
220
+
221
+ # Parse arguments
222
+ i = 0
223
+ while i < len(argv):
224
+ arg = argv[i]
225
+ if arg == "--root":
226
+ i += 1
227
+ if i < len(argv):
228
+ repo_root = argv[i]
229
+ i += 1
230
+ elif arg.startswith("--root="):
231
+ repo_root = arg[len("--root="):]
232
+ i += 1
233
+ elif arg == "--json":
234
+ output_format = "json"
235
+ i += 1
236
+ elif arg == "--baseline":
237
+ i += 1
238
+ if i < len(argv):
239
+ baseline_file = argv[i]
240
+ i += 1
241
+ elif arg == "--update-baseline":
242
+ update_baseline = True
243
+ i += 1
244
+ else:
245
+ print(f"Unknown argument: {arg}", file=sys.stderr)
246
+ return 2
247
+
248
+ if repo_root is None:
249
+ repo_root = Path.cwd()
250
+ else:
251
+ repo_root = Path(repo_root)
252
+
253
+ if baseline_file is None:
254
+ baseline_file = repo_root / ".stateapi-baseline.json"
255
+
256
+ # Scan for violations
257
+ violations = find_direct_opens(str(repo_root))
258
+
259
+ # Load baseline
260
+ baseline_data = load_baseline(str(baseline_file))
261
+ baseline_violations = baseline_data.get("violations", [])
262
+
263
+ # If --update-baseline, save current as baseline and exit
264
+ if update_baseline:
265
+ save_baseline(baseline_file, {"violations": violations})
266
+ if output_format == "json":
267
+ print(json.dumps({"ok": True, "message": "Baseline updated", "count": len(violations)}, indent=2))
268
+ else:
269
+ print(f"Baseline updated: {len(violations)} violation(s) recorded")
270
+ return 0
271
+
272
+ # Check ratchet: baseline must match current exactly
273
+ is_ok, stale_entries, new_violations = check_ratchet(baseline_violations, violations)
274
+
275
+ if output_format == "json":
276
+ result = {
277
+ "ok": is_ok,
278
+ "violations": violations,
279
+ "baseline_count": len(baseline_violations),
280
+ "current_count": len(violations),
281
+ "stale_entries": stale_entries,
282
+ "new_violations": new_violations,
283
+ }
284
+ print(json.dumps(result, indent=2))
285
+ else:
286
+ # Text format
287
+ print(f"State API lint: {len(violations)} violation(s) found")
288
+ if baseline_violations:
289
+ print(f" (baseline: {len(baseline_violations)})")
290
+
291
+ if violations:
292
+ for v in sorted(violations):
293
+ print(f" {v}")
294
+
295
+ # Report stale entries (in baseline but not current)
296
+ if stale_entries:
297
+ print(f"\nSTALE baseline entries ({len(stale_entries)}) — hand-edited or fixed?:")
298
+ for v in stale_entries:
299
+ print(f" {v}")
300
+
301
+ # Report new violations (in current but not baseline)
302
+ if new_violations:
303
+ print(f"\nNEW violations ({len(new_violations)}):")
304
+ for v in new_violations:
305
+ print(f" {v}")
306
+
307
+ # Final verdict
308
+ if is_ok:
309
+ if violations:
310
+ print(f"\nPASS: All {len(violations)} violations match baseline")
311
+ else:
312
+ print("\nPASS: No violations")
313
+ return 0
314
+ else:
315
+ if stale_entries and new_violations:
316
+ print(f"\nFAIL: {len(stale_entries)} stale + {len(new_violations)} new violation(s)")
317
+ elif stale_entries:
318
+ print(f"\nFAIL: {len(stale_entries)} stale baseline entries (hand-edited?)")
319
+ else:
320
+ print(f"\nFAIL: {len(new_violations)} new violation(s) detected")
321
+ return 1
322
+
323
+
324
+ if __name__ == "__main__":
325
+ sys.exit(main())
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env python3
2
+ """test_battery.py -- run the local union test battery, parallel by default.
3
+
4
+ Runs the four harnesses (python unittest discover, node --test, shell suites,
5
+ ui vitest+tsc) as concurrent subprocesses with per-harness rc capture, stdin
6
+ closed (the hook suite hangs on never-EOF stdin), and an explicit summary
7
+ table. Exit 0 only when every harness exits 0.
8
+
9
+ Per-harness timeout (AESOP_BATTERY_HARNESS_TIMEOUT_S, default 1800s = 30min):
10
+ on expiry, the process tree is killed and rc=124 is recorded with a TIMEOUT
11
+ note. Applies in both serial and parallel modes.
12
+
13
+ Usage:
14
+ python tools/test_battery.py [--serial] [--skip ui|sh|node|py ...] [--json]
15
+
16
+ --serial runs harnesses one at a time (the pre-wave-29 behavior; fallback if
17
+ parallel runs prove load-fragile on a box). Logs land in the state scratch dir
18
+ (AESOP_BATTERY_LOGDIR or the system temp dir) as battery-<harness>.log.
19
+ """
20
+ import argparse
21
+ import json
22
+ import os
23
+ import platform
24
+ import signal
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+ import time
29
+ from pathlib import Path
30
+
31
+ REPO = Path(__file__).resolve().parent.parent
32
+
33
+ HARNESSES = {
34
+ "py": [sys.executable, "-m", "unittest", "discover", "-s", "tests"],
35
+ "node": ["npm", "run", "test:node"],
36
+ "sh": ["npm", "run", "test:sh"],
37
+ "ui": None, # composite: tsc + vitest, run via _ui_command
38
+ }
39
+
40
+
41
+ def _get_harness_timeout():
42
+ """Get per-harness timeout in seconds (env AESOP_BATTERY_HARNESS_TIMEOUT_S, default 1800)."""
43
+ timeout_s = os.environ.get("AESOP_BATTERY_HARNESS_TIMEOUT_S", "1800")
44
+ try:
45
+ return int(timeout_s)
46
+ except ValueError:
47
+ return 1800
48
+
49
+
50
+ def _kill_process_tree(proc):
51
+ """Kill process and all children (Windows: taskkill /T /F, others: SIGKILL)."""
52
+ try:
53
+ if platform.system() == "Windows":
54
+ subprocess.run(
55
+ ["taskkill", "/PID", str(proc.pid), "/T", "/F"],
56
+ capture_output=True,
57
+ timeout=5,
58
+ )
59
+ else:
60
+ # Unix: SIGKILL via os.killpg (process group)
61
+ try:
62
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
63
+ except (ProcessLookupError, OSError):
64
+ # Process already gone or not in a group; try direct kill
65
+ try:
66
+ os.kill(proc.pid, signal.SIGKILL)
67
+ except (ProcessLookupError, OSError):
68
+ pass
69
+ except Exception:
70
+ # Ignore kill errors; process may already be dead
71
+ pass
72
+
73
+
74
+ def _wait_with_timeout(proc, timeout_s):
75
+ """Wait for process with timeout; return (rc, timed_out).
76
+
77
+ On timeout, kills the process tree and returns (124, True).
78
+ Otherwise returns (proc.returncode, False).
79
+ """
80
+ try:
81
+ proc.wait(timeout=timeout_s)
82
+ return proc.returncode, False
83
+ except subprocess.TimeoutExpired:
84
+ _kill_process_tree(proc)
85
+ # Wait a moment for kill to take effect, then force reap
86
+ try:
87
+ proc.wait(timeout=1)
88
+ except subprocess.TimeoutExpired:
89
+ pass
90
+ return 124, True
91
+
92
+
93
+ def _ui_command():
94
+ # ui/web needs node_modules; npx resolves local binaries.
95
+ return "npx tsc --noEmit && npx vitest run --silent"
96
+
97
+
98
+ def run_harness(name, logdir, parallel=True):
99
+ """Spawn one harness with stdin closed; return (name, Popen, logfile)."""
100
+ log = Path(logdir) / f"battery-{name}.log"
101
+ f = open(log, "w", encoding="utf-8", errors="replace")
102
+ env = os.environ.copy()
103
+ if parallel and name == "node" and "AESOP_TEST_CHILD_TIMEOUT_MS" not in env:
104
+ # Under 4-harness parallel load, scaffold child processes legitimately
105
+ # exceed the 30s solo ceiling; the tests honor this knob (wave-29).
106
+ env["AESOP_TEST_CHILD_TIMEOUT_MS"] = "90000"
107
+ if name == "ui":
108
+ proc = subprocess.Popen(
109
+ _ui_command(), shell=True, cwd=str(REPO / "ui" / "web"),
110
+ stdin=subprocess.DEVNULL, stdout=f, stderr=subprocess.STDOUT, env=env,
111
+ )
112
+ else:
113
+ # npm needs shell resolution on Windows.
114
+ use_shell = os.name == "nt" and HARNESSES[name][0] == "npm"
115
+ cmd = " ".join(HARNESSES[name]) if use_shell else HARNESSES[name]
116
+ proc = subprocess.Popen(
117
+ cmd, shell=use_shell, cwd=str(REPO),
118
+ stdin=subprocess.DEVNULL, stdout=f, stderr=subprocess.STDOUT, env=env,
119
+ )
120
+ return name, proc, f, log
121
+
122
+
123
+ def main():
124
+ ap = argparse.ArgumentParser(description="Run the local union test battery")
125
+ ap.add_argument("--serial", action="store_true", help="run harnesses sequentially")
126
+ ap.add_argument("--skip", action="append", default=[], choices=list(HARNESSES),
127
+ help="skip a harness (repeatable)")
128
+ ap.add_argument("--json", action="store_true", help="machine-readable summary")
129
+ args = ap.parse_args()
130
+
131
+ logdir = os.environ.get("AESOP_BATTERY_LOGDIR") or tempfile.mkdtemp(prefix="aesop-battery-")
132
+ os.makedirs(logdir, exist_ok=True)
133
+ names = [n for n in HARNESSES if n not in args.skip]
134
+ started = time.time()
135
+ results = {}
136
+ timeout_s = _get_harness_timeout()
137
+
138
+ if args.serial:
139
+ for n in names:
140
+ name, proc, f, log = run_harness(n, logdir, parallel=False)
141
+ rc, timed_out = _wait_with_timeout(proc, timeout_s)
142
+ f.close()
143
+ result = {"rc": rc, "log": str(log)}
144
+ if timed_out:
145
+ result["note"] = "TIMEOUT"
146
+ results[name] = result
147
+ else:
148
+ procs = [run_harness(n, logdir) for n in names]
149
+ for name, proc, f, log in procs:
150
+ rc, timed_out = _wait_with_timeout(proc, timeout_s)
151
+ f.close()
152
+ result = {"rc": rc, "log": str(log)}
153
+ if timed_out:
154
+ result["note"] = "TIMEOUT"
155
+ results[name] = result
156
+
157
+ wall = round(time.time() - started, 1)
158
+ ok = all(r["rc"] == 0 for r in results.values())
159
+ if args.json:
160
+ print(json.dumps({"ok": ok, "wall_s": wall, "mode": "serial" if args.serial else "parallel",
161
+ "results": results}))
162
+ else:
163
+ print(f"battery mode={'serial' if args.serial else 'parallel'} wall={wall}s")
164
+ for n, r in results.items():
165
+ verdict = "PASS" if r["rc"] == 0 else "FAIL"
166
+ note_str = f" {r['note']}" if "note" in r else ""
167
+ print(f" {n:5s} rc={r['rc']} {verdict}{note_str} log={r['log']}")
168
+ print("BATTERY:", "GREEN" if ok else "RED")
169
+ sys.exit(0 if ok else 1)
170
+
171
+
172
+ if __name__ == "__main__":
173
+ main()