@matt82198/aesop 0.1.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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -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()
@@ -0,0 +1,380 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ transcript_digest.py — Digest agent transcripts into compact briefs.
4
+
5
+ Reads agent-*.jsonl transcripts from a session subagents directory and appends
6
+ compact ~200-byte per-agent briefs to state/ledger/transcripts-brief.jsonl.
7
+
8
+ Usage:
9
+ python -m tools.transcript_digest --transcripts-dir /path/to/session/subagents \
10
+ --wave rc.6 [--state-root /path/to/state]
11
+
12
+ Briefs include: agent label, files touched, tool-call count, pass/fail outcome,
13
+ token count, and a 1-2 sentence summary. Aggressive redaction removes absolute
14
+ paths, usernames, emails, tokens, and repo names.
15
+
16
+ Deterministic + idempotent (skips agents already in the ledger).
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Dict, List, Optional, Set, Tuple
27
+
28
+
29
+ # Redaction patterns (derived from secret_scan.py)
30
+ REDACTION_PATTERNS = { # secretscan: allow-pattern-docs
31
+ "pem_private_key": (r"-----BEGIN .* PRIVATE " r"KEY-----.*?-----END .* PRIVATE " r"KEY-----", re.DOTALL | re.IGNORECASE),
32
+ "aws_access_key": (r"AKIA[0-9A-Z]{16}", 0),
33
+ "aws_secret_pattern": (r"aws[_-]?secret[_-]?access[_-]?key\s*[:=]\s*[^\s\$\<\{]", re.IGNORECASE),
34
+ "github_token": (r"(ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{20,}", 0),
35
+ "slack_token": (r"xox[baprs]-[A-Za-z0-9-]{10,}", 0),
36
+ "openai_anthropic_key": (r"sk-[A-Za-z0-9_\-]{20,}", 0),
37
+ "connection_string": (r"://[^:]+:[^@/\s]+@(?!localhost|127\.|example|test)[^\s]+", 0),
38
+ }
39
+
40
+ # Patterns to redact by category
41
+ EMAIL_PATTERN = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
42
+ # Windows path: C:\ or POSIX path: /
43
+ PATH_PATTERN = r"[A-Za-z]:\\[^\\/:*<>|]*|/[^/:*<>|]*"
44
+ REPO_NAME_PATTERN = r"\b(?:aesop|conductor3|tr-sample-tracker|ecm-ai|TR-Automation-Scripts)\b"
45
+ USERNAME_PATTERN = r"\b(?:matt8|matt82198|John|Jack|Doe)\b"
46
+
47
+
48
+ def redact_text(text: str) -> str:
49
+ """Aggressively redact secrets, paths, emails, usernames, and repo names."""
50
+ if not text:
51
+ return text
52
+
53
+ # Redact keys and credentials
54
+ for pattern, flags in REDACTION_PATTERNS.values():
55
+ text = re.sub(pattern, "[REDACTED]", text, flags=flags)
56
+
57
+ # Redact emails
58
+ text = re.sub(EMAIL_PATTERN, "[EMAIL]", text, flags=re.IGNORECASE)
59
+
60
+ # Redact absolute paths (Windows and POSIX)
61
+ text = re.sub(PATH_PATTERN, "[PATH]", text)
62
+
63
+ # Redact repo names
64
+ text = re.sub(REPO_NAME_PATTERN, "[REPO]", text, flags=re.IGNORECASE)
65
+
66
+ # Redact usernames
67
+ text = re.sub(USERNAME_PATTERN, "[USER]", text, flags=re.IGNORECASE)
68
+
69
+ return text
70
+
71
+
72
+ def infer_outcome(messages: List[Dict], errors: List[Dict]) -> str:
73
+ """Infer outcome from transcript structure: completed|stalled|failed|timeout."""
74
+ # Check for explicit timeout markers first
75
+ if errors:
76
+ if any("timeout" in e.get("message", "").lower() for e in errors):
77
+ return "timeout"
78
+ return "stalled"
79
+
80
+ if not messages:
81
+ return "failed"
82
+
83
+ last_msg = messages[-1] if isinstance(messages, list) else {}
84
+ msg_type = last_msg.get("type", "")
85
+
86
+ # If last message is a tool result with error, mark as stalled
87
+ if msg_type == "tool_result" and "error" in last_msg:
88
+ return "stalled"
89
+
90
+ # Default to completed
91
+ return "completed"
92
+
93
+
94
+ def extract_files_from_calls(messages: List[Dict]) -> Tuple[Set[str], Set[str]]:
95
+ """Extract created and modified file paths from tool calls."""
96
+ created = set()
97
+ modified = set()
98
+
99
+ if not isinstance(messages, list):
100
+ return created, modified
101
+
102
+ for msg in messages:
103
+ if msg.get("type") == "tool_result" or "content" in msg:
104
+ content = msg.get("content", "")
105
+ if isinstance(content, str):
106
+ # Look for Write, Edit, Read operations in logs
107
+ if "Write" in content or "write" in content:
108
+ # Heuristic: files mentioned after write ops are likely created
109
+ matches = re.findall(r"(?:Write|write|created?|added?)\s+(?:to\s+)?['\"]?([^\s'\"]+\.(?:py|js|md|json|sh))", content)
110
+ created.update(matches)
111
+ if "Edit" in content or "edit" in content or "modified?" in content:
112
+ matches = re.findall(r"(?:Edit|edit|modified?|updated?)\s+(?:file\s+)?['\"]?([^\s'\"]+\.(?:py|js|md|json|sh))", content)
113
+ modified.update(matches)
114
+
115
+ # Redact file paths
116
+ created = {redact_text(f) for f in created}
117
+ modified = {redact_text(f) for f in modified}
118
+
119
+ return created, modified
120
+
121
+
122
+ def extract_tool_calls(messages: List[Dict]) -> Tuple[List[str], int]:
123
+ """Extract tool call types and count."""
124
+ tools = {}
125
+
126
+ if not isinstance(messages, list):
127
+ return [], 0
128
+
129
+ for msg in messages:
130
+ if msg.get("type") == "tool_use":
131
+ tool_name = msg.get("name", "Unknown")
132
+ tools[tool_name] = tools.get(tool_name, 0) + 1
133
+
134
+ # Sort by frequency, take top 3
135
+ top_tools = sorted(tools.keys(), key=lambda t: tools[t], reverse=True)[:3]
136
+ total_calls = sum(tools.values())
137
+
138
+ return top_tools, total_calls
139
+
140
+
141
+ def extract_errors(messages: List[Dict]) -> List[Dict]:
142
+ """Extract error messages with timestamps."""
143
+ errors = []
144
+
145
+ if not isinstance(messages, list):
146
+ return errors
147
+
148
+ for i, msg in enumerate(messages):
149
+ if msg.get("type") == "tool_result" and msg.get("is_error"):
150
+ error_msg = msg.get("content", "Unknown error")
151
+ # Redact sensitive data from error messages
152
+ error_msg = redact_text(error_msg)
153
+ # Truncate to ~100 chars
154
+ error_msg = error_msg[:100]
155
+ errors.append({
156
+ "tool": msg.get("name", "Unknown"),
157
+ "at_sec": i * 5, # Rough estimate: ~5 sec per message
158
+ "message": error_msg
159
+ })
160
+
161
+ return errors[:3] # Keep top 3 errors
162
+
163
+
164
+ def extract_token_usage(metadata: Dict) -> Dict:
165
+ """Extract token usage from metadata."""
166
+ usage = metadata.get("usage", {})
167
+ return {
168
+ "input": usage.get("input_tokens", 0),
169
+ "output": usage.get("output_tokens", 0),
170
+ "model": metadata.get("model", "haiku")
171
+ }
172
+
173
+
174
+ def generate_brief(
175
+ messages: List[Dict],
176
+ tool_calls: List[str],
177
+ files_created: Set[str],
178
+ files_modified: Set[str],
179
+ errors: List[Dict]
180
+ ) -> str:
181
+ """Generate a 1-2 sentence summary of the transcript."""
182
+ parts = []
183
+
184
+ if tool_calls:
185
+ parts.append(f"Used {len(tool_calls)} tool types ({', '.join(tool_calls[:2])})")
186
+
187
+ total_files = len(files_created) + len(files_modified)
188
+ if total_files > 0:
189
+ parts.append(f"modified {total_files} file(s)")
190
+
191
+ if errors:
192
+ parts.append(f"encountered {len(errors)} error(s)")
193
+ else:
194
+ parts.append("completed without errors")
195
+
196
+ brief = "; ".join(parts) + "."
197
+
198
+ # Truncate to ~150 chars max
199
+ if len(brief) > 150:
200
+ brief = brief[:147] + "..."
201
+
202
+ return brief
203
+
204
+
205
+ def stream_jsonl_transcripts(transcripts_dir: Path) -> Dict[str, Tuple[Dict, List]]:
206
+ """Stream parse all agent-*.jsonl files in directory."""
207
+ agents = {}
208
+
209
+ if not transcripts_dir.exists():
210
+ return agents
211
+
212
+ for jsonl_file in sorted(transcripts_dir.glob("agent-*.jsonl")):
213
+ agent_id = jsonl_file.stem.replace("agent-", "")
214
+ messages = []
215
+ metadata = {}
216
+
217
+ try:
218
+ with open(jsonl_file, "r", encoding="utf-8") as f:
219
+ for line in f:
220
+ if not line.strip():
221
+ continue
222
+ try:
223
+ obj = json.loads(line)
224
+ if obj.get("type") == "metadata":
225
+ metadata = obj
226
+ else:
227
+ messages.append(obj)
228
+ except json.JSONDecodeError:
229
+ continue
230
+ except Exception:
231
+ continue
232
+
233
+ if messages or metadata:
234
+ agents[agent_id] = (metadata, messages)
235
+
236
+ return agents
237
+
238
+
239
+ def create_brief(
240
+ wave: str,
241
+ agent_id: str,
242
+ metadata: Dict,
243
+ messages: List[Dict]
244
+ ) -> Dict:
245
+ """Create a brief dict from transcript data."""
246
+ start_time = metadata.get("start_time", datetime.now(timezone.utc).isoformat())
247
+ end_time = metadata.get("end_time", datetime.now(timezone.utc).isoformat())
248
+
249
+ # Parse timestamps to compute duration
250
+ try:
251
+ start = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
252
+ end = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
253
+ duration_sec = int((end - start).total_seconds())
254
+ except Exception:
255
+ duration_sec = 0
256
+
257
+ tool_calls, tool_count = extract_tool_calls(messages)
258
+ files_created, files_modified = extract_files_from_calls(messages)
259
+ errors = extract_errors(messages)
260
+ outcome = infer_outcome(messages, errors)
261
+ token_usage = extract_token_usage(metadata)
262
+ brief_text = generate_brief(messages, tool_calls, files_created, files_modified, errors)
263
+
264
+ return {
265
+ "wave": wave,
266
+ "agent_id": agent_id,
267
+ "start_time": start_time,
268
+ "end_time": end_time,
269
+ "duration_sec": duration_sec,
270
+ "outcome": outcome,
271
+ "top_tool_calls": tool_calls,
272
+ "files_created": sorted(list(files_created)),
273
+ "files_modified": sorted(list(files_modified)),
274
+ "errors": errors,
275
+ "token_usage": token_usage,
276
+ "brief": brief_text,
277
+ "brief_schema_version": 1
278
+ }
279
+
280
+
281
+ def get_existing_agent_ids(ledger_path: Path) -> Set[str]:
282
+ """Read already-digested agent IDs from ledger."""
283
+ agent_ids = set()
284
+
285
+ if not ledger_path.exists():
286
+ return agent_ids
287
+
288
+ try:
289
+ with open(ledger_path, "r", encoding="utf-8") as f:
290
+ for line in f:
291
+ if not line.strip():
292
+ continue
293
+ try:
294
+ obj = json.loads(line)
295
+ agent_ids.add(obj.get("agent_id", ""))
296
+ except json.JSONDecodeError:
297
+ continue
298
+ except Exception:
299
+ pass
300
+
301
+ return agent_ids
302
+
303
+
304
+ def append_briefs(ledger_path: Path, briefs: List[Dict]) -> int:
305
+ """Append briefs to ledger file (create parent dir if needed)."""
306
+ if not briefs:
307
+ return 0
308
+
309
+ # Ensure parent directory exists
310
+ ledger_path.parent.mkdir(parents=True, exist_ok=True)
311
+
312
+ # Append briefs
313
+ try:
314
+ with open(ledger_path, "a", encoding="utf-8") as f:
315
+ for brief in briefs:
316
+ f.write(json.dumps(brief) + "\n")
317
+ return len(briefs)
318
+ except Exception:
319
+ return 0
320
+
321
+
322
+ def main():
323
+ parser = argparse.ArgumentParser(
324
+ description=__doc__,
325
+ formatter_class=argparse.RawDescriptionHelpFormatter
326
+ )
327
+ parser.add_argument(
328
+ "--transcripts-dir",
329
+ type=Path,
330
+ required=True,
331
+ help="Directory containing agent-*.jsonl files"
332
+ )
333
+ parser.add_argument(
334
+ "--wave",
335
+ default="unknown",
336
+ help="Wave ID for the briefs (default: unknown)"
337
+ )
338
+ parser.add_argument(
339
+ "--state-root",
340
+ type=Path,
341
+ default=Path(os.environ.get("AESOP_STATE_ROOT", "./state")),
342
+ help="State directory root (default: $AESOP_STATE_ROOT or ./state)"
343
+ )
344
+
345
+ args = parser.parse_args()
346
+
347
+ # Resolve paths
348
+ transcripts_dir = args.transcripts_dir.resolve()
349
+ ledger_path = args.state_root / "ledger" / "transcripts-brief.jsonl"
350
+
351
+ if not transcripts_dir.exists():
352
+ print(f"ERROR: transcripts directory not found: {transcripts_dir}", file=sys.stderr)
353
+ sys.exit(1)
354
+
355
+ # Read existing agent IDs (for idempotency)
356
+ existing_ids = get_existing_agent_ids(ledger_path)
357
+
358
+ # Stream and digest transcripts
359
+ agents = stream_jsonl_transcripts(transcripts_dir)
360
+ briefs_to_append = []
361
+
362
+ for agent_id, (metadata, messages) in sorted(agents.items()):
363
+ # Skip if already digested
364
+ if agent_id in existing_ids:
365
+ continue
366
+
367
+ brief = create_brief(args.wave, agent_id, metadata, messages)
368
+ briefs_to_append.append(brief)
369
+
370
+ # Append to ledger
371
+ count = append_briefs(ledger_path, briefs_to_append)
372
+
373
+ if count > 0:
374
+ print(f"Appended {count} brief(s) to {ledger_path}")
375
+
376
+ sys.exit(0)
377
+
378
+
379
+ if __name__ == "__main__":
380
+ main()