@matt82198/aesop 0.1.0 → 0.2.0

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 (114) hide show
  1. package/CHANGELOG.md +89 -2
  2. package/README.md +63 -12
  3. package/bin/cli.js +164 -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/INSTALL.md +70 -1
  10. package/docs/QUICKSTART.md +80 -0
  11. package/docs/README.md +33 -3
  12. package/docs/TEAM-STATE.md +540 -0
  13. package/driver/CLAUDE.md +148 -0
  14. package/driver/README.md +383 -0
  15. package/driver/aesop.config.example.json +80 -0
  16. package/driver/agent_driver.py +355 -0
  17. package/driver/backend_config.py +253 -0
  18. package/driver/claude_code_driver.py +198 -0
  19. package/driver/codex_driver.py +626 -0
  20. package/driver/openai_compatible_driver.py +249 -0
  21. package/driver/openai_transport.py +146 -0
  22. package/driver/verification_policy.py +75 -0
  23. package/driver/wave_bridge.py +246 -0
  24. package/driver/wave_loop.py +1041 -0
  25. package/hooks/pre-push-policy.sh +109 -28
  26. package/mcp/server.mjs +320 -4
  27. package/package.json +23 -15
  28. package/skills/CLAUDE.md +132 -2
  29. package/skills/buildsystem/SKILL.md +330 -0
  30. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  31. package/skills/fleet/SKILL.md +113 -0
  32. package/skills/power/SKILL.md +246 -131
  33. package/state_store/__init__.py +2 -1
  34. package/state_store/api.py +19 -4
  35. package/state_store/coordination.py +209 -0
  36. package/state_store/identity.py +51 -0
  37. package/state_store/store.py +185 -73
  38. package/templates/wave-presets/data.json +65 -0
  39. package/templates/wave-presets/library.json +65 -0
  40. package/templates/wave-presets/saas.json +64 -0
  41. package/tools/audit_report.py +388 -0
  42. package/tools/bench_runner.py +100 -3
  43. package/tools/ci_merge_wait.py +256 -35
  44. package/tools/ci_workflow_lint.py +430 -0
  45. package/tools/claudemd_drift.py +394 -0
  46. package/tools/claudemd_lint.py +359 -0
  47. package/tools/common.py +39 -3
  48. package/tools/cost_ceiling.py +63 -31
  49. package/tools/cost_econ.py +480 -0
  50. package/tools/defect_escape.py +252 -0
  51. package/tools/doctor.js +1 -1
  52. package/tools/eod_sweep.py +58 -0
  53. package/tools/fleet.js +260 -0
  54. package/tools/fleet_ledger.py +209 -7
  55. package/tools/git_identity_check.py +315 -0
  56. package/tools/health-score.js +40 -0
  57. package/tools/health_score.py +361 -0
  58. package/tools/metrics_gate.py +13 -4
  59. package/tools/mutation_test.py +401 -0
  60. package/tools/portability_check.py +206 -0
  61. package/tools/reconcile.py +7 -4
  62. package/tools/secret_scan.py +137 -50
  63. package/tools/self_stats.py +20 -0
  64. package/tools/transcript_digest.py +380 -0
  65. package/tools/verify_activity_filter.py +437 -0
  66. package/tools/verify_agent_inspector.py +2 -0
  67. package/tools/verify_dash.py +2 -0
  68. package/tools/verify_dispatch_panel.py +301 -0
  69. package/tools/verify_failure_drilldown.py +188 -0
  70. package/tools/verify_prboard.py +2 -0
  71. package/tools/verify_scorecards.py +281 -0
  72. package/tools/verify_submit_encoding.py +2 -0
  73. package/tools/verify_ui_trio.py +409 -0
  74. package/tools/verify_wave_telemetry.py +268 -0
  75. package/tools/wave_backlog_analyzer.py +490 -0
  76. package/tools/wave_ledger_hook.py +150 -0
  77. package/tools/wave_preflight.py +512 -0
  78. package/tools/wave_resume.py +215 -0
  79. package/tools/wave_templates.py +215 -0
  80. package/ui/agents.py +68 -14
  81. package/ui/collectors.py +0 -55
  82. package/ui/config.py +7 -2
  83. package/ui/cost.py +231 -12
  84. package/ui/handler.py +183 -0
  85. package/ui/quality_scorecard.py +232 -0
  86. package/ui/wave_audit_tail.py +213 -0
  87. package/ui/wave_dispatch.py +280 -0
  88. package/ui/wave_failure.py +288 -0
  89. package/ui/wave_gantt.py +152 -0
  90. package/ui/wave_reasoning_tail.py +176 -0
  91. package/ui/wave_telemetry.py +377 -0
  92. package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
  93. package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
  94. package/ui/web/dist/index.html +2 -2
  95. package/bin/CLAUDE.md +0 -76
  96. package/daemons/CLAUDE.md +0 -36
  97. package/dash/CLAUDE.md +0 -32
  98. package/docs/archive/README.md +0 -3
  99. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  100. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  101. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  102. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  103. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  104. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  105. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  106. package/hooks/CLAUDE.md +0 -89
  107. package/mcp/CLAUDE.md +0 -213
  108. package/monitor/CLAUDE.md +0 -40
  109. package/scan/CLAUDE.md +0 -30
  110. package/state_store/CLAUDE.md +0 -39
  111. package/tools/CLAUDE.md +0 -79
  112. package/ui/CLAUDE.md +0 -127
  113. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  114. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -8,7 +8,7 @@ Modes:
8
8
  secret_scan.py --history [--repo PATH] Scan all blobs in git history
9
9
  secret_scan.py PATH [PATH...] Scan files/dirs directly (recurse dirs)
10
10
 
11
- Exit codes: 0=clean, 1=findings, 2=usage error
11
+ Exit codes: 0=clean, 1=findings, 2=error (file unreadable/git failure/scan error)
12
12
  Output: one line per finding or summary (never prints full secrets)
13
13
 
14
14
  Pragma escape hatch (STRICTLY SCOPED to doc-shaped rules):
@@ -145,6 +145,12 @@ class GitScanError(Exception):
145
145
  printed CLEAN and exited 0 on an unresolvable range)."""
146
146
 
147
147
 
148
+ class ScanError(Exception):
149
+ """Raised when a file cannot be scanned due to read/permission/OS errors.
150
+ Callers MUST treat this as fail-CLOSED (block / exit 2) -- a file the
151
+ scanner cannot read must block the push, not sail through as CLEAN."""
152
+
153
+
148
154
  def has_pragma(filepath):
149
155
  """Check if file has 'secretscan: allow-pattern-docs' pragma in first 10 lines."""
150
156
  try:
@@ -230,6 +236,9 @@ def scan_file(filepath):
230
236
  Large files (>1MB) and binary files are scanned for FATAL_RULES patterns:
231
237
  - Large files: scan first 1MB; emit SKIPPED-LARGE to stderr if file is larger
232
238
  - Binary files: decode as latin-1; emit SKIPPED-BINARY to stderr if not fully scanned
239
+
240
+ Raises ScanError if file cannot be read (permission denied, vanished, etc.)
241
+ to fail CLOSED rather than reporting the file clean.
233
242
  """
234
243
  SIZE_THRESHOLD = 1024 * 1024 # 1MB
235
244
  MAX_READ_SIZE = 2 * 1024 * 1024 # 2MB max to read
@@ -240,6 +249,7 @@ def scan_file(filepath):
240
249
  return findings
241
250
 
242
251
  # Check for pragma (applies only to specific rule-based findings, not filename findings)
252
+ # has_pragma() already swallows its own exceptions, so safe to call
243
253
  has_file_pragma = has_pragma(filepath)
244
254
 
245
255
  # Check if filename matches credential patterns (always fatal, pragma does NOT apply)
@@ -251,13 +261,21 @@ def scan_file(filepath):
251
261
  )
252
262
  break
253
263
 
264
+ # File stat and type detection — not wrapped in try/except so errors propagate
254
265
  try:
255
- # Check file size and binary status
256
266
  stat = filepath.stat()
257
267
  file_size = stat.st_size
268
+ except (OSError, PermissionError, FileNotFoundError) as e:
269
+ raise ScanError(f"Cannot stat {filepath}: {e}")
270
+
271
+ try:
258
272
  is_binary = is_binary_file(filepath)
259
- is_large = file_size > SIZE_THRESHOLD
273
+ except (OSError, PermissionError, FileNotFoundError) as e:
274
+ raise ScanError(f"Cannot check if binary {filepath}: {e}")
275
+
276
+ is_large = file_size > SIZE_THRESHOLD
260
277
 
278
+ try:
261
279
  if is_binary:
262
280
  # Binary file (any size): scan as-is for FATAL_RULES only
263
281
  with open(filepath, "rb") as f:
@@ -289,31 +307,26 @@ def scan_file(filepath):
289
307
  # Large text file: scan entire file in chunks for all rules
290
308
  print(f"SKIPPED-LARGE {filepath} (scanned in chunks)", file=sys.stderr)
291
309
  line_num = 0
292
- try:
293
- with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
294
- # Read in 1MB chunks to avoid loading entire large file into memory
295
- for chunk in iter(lambda: f.read(1024 * 1024), ""):
296
- for chunk_line in chunk.split("\n"):
297
- line_num += 1
298
- for rule_name, (pattern, flags) in PATTERNS.items():
299
- # Skip env_assignment rule if not an .env-like file
300
- if rule_name == "env_assignment" and not is_env_file(filepath):
301
- continue
310
+ with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
311
+ # Read in 1MB chunks to avoid loading entire large file into memory
312
+ for chunk in iter(lambda: f.read(1024 * 1024), ""):
313
+ for chunk_line in chunk.split("\n"):
314
+ line_num += 1
315
+ for rule_name, (pattern, flags) in PATTERNS.items():
316
+ # Skip env_assignment rule if not an .env-like file
317
+ if rule_name == "env_assignment" and not is_env_file(filepath):
318
+ continue
302
319
 
303
- matches = re.finditer(pattern, chunk_line, flags)
304
- for match in matches:
305
- match_str = match.group(0)
320
+ matches = re.finditer(pattern, chunk_line, flags)
321
+ for match in matches:
322
+ match_str = match.group(0)
306
323
 
307
- # Skip if it's a placeholder
308
- if is_placeholder(match_str):
309
- continue
324
+ # Skip if it's a placeholder
325
+ if is_placeholder(match_str):
326
+ continue
310
327
 
311
- is_fatal = _classify_finding(rule_name, has_file_pragma)
312
- findings.append((line_num, rule_name, match_str, is_fatal))
313
- except (IOError, OSError) as e:
314
- # FAIL CLOSED: if we cannot fully scan a large text file, exit with error
315
- print(f"FATAL: Cannot fully scan large text file {filepath}: {e}", file=sys.stderr)
316
- sys.exit(1)
328
+ is_fatal = _classify_finding(rule_name, has_file_pragma)
329
+ findings.append((line_num, rule_name, match_str, is_fatal))
317
330
 
318
331
  else:
319
332
  # Normal small text file: scan all rules
@@ -335,8 +348,9 @@ def scan_file(filepath):
335
348
  is_fatal = _classify_finding(rule_name, has_file_pragma)
336
349
  findings.append((line_num, rule_name, match_str, is_fatal))
337
350
 
338
- except Exception:
339
- pass
351
+ except (OSError, PermissionError, FileNotFoundError, IOError) as e:
352
+ # FAIL CLOSED: if file cannot be read, raise error (not return clean)
353
+ raise ScanError(f"Cannot scan file {filepath}: {e}")
340
354
 
341
355
  return findings
342
356
 
@@ -528,7 +542,11 @@ def get_range_files(repo_path, commit_range):
528
542
 
529
543
 
530
544
  def get_history_files(repo_path):
531
- """Get all file contents from git history via git log -p."""
545
+ """Get all file contents from git history via git log -p.
546
+
547
+ Raises GitScanError if git log fails (corrupt repo, git crash, etc.).
548
+ This mirrors get_range_files() and get_staged_files() which already
549
+ fail closed on git errors."""
532
550
  files_content = []
533
551
  try:
534
552
  # Use git log -p to get full diff history
@@ -539,38 +557,96 @@ def get_history_files(repo_path):
539
557
  text=True,
540
558
  timeout=60,
541
559
  )
542
- if result.returncode == 0:
543
- # Parse the git log output: each file appears as +++ b/path/to/file followed by its content
544
- current_file = None
545
- current_content = []
546
- for line in result.stdout.split("\n"):
547
- if line.startswith("+++ b/"):
548
- if current_file and current_content:
549
- files_content.append((current_file, "\n".join(current_content)))
550
- current_file = line[6:] # Remove "+++ b/"
551
- current_content = []
552
- elif current_file and line.startswith("+") and not line.startswith("+++"):
553
- # Content line (added), strip the leading +
554
- current_content.append(line[1:])
555
- elif current_file and line.startswith(" "):
556
- # Context line, keep it as-is (strip leading space)
557
- current_content.append(line[1:])
560
+ except Exception as e:
561
+ raise GitScanError(f"git log raised {e!r}")
562
+
563
+ if result.returncode != 0:
564
+ raise GitScanError(
565
+ f"git log failed (rc={result.returncode}): {result.stderr.strip()}"
566
+ )
567
+
568
+ # Parse the git log output: each file appears as +++ b/path/to/file followed by its content
569
+ current_file = None
570
+ current_content = []
571
+ for line in result.stdout.split("\n"):
572
+ if line.startswith("+++ b/"):
558
573
  if current_file and current_content:
559
574
  files_content.append((current_file, "\n".join(current_content)))
560
- except Exception:
561
- pass
575
+ current_file = line[6:] # Remove "+++ b/"
576
+ current_content = []
577
+ elif current_file and line.startswith("+") and not line.startswith("+++"):
578
+ # Content line (added), strip the leading +
579
+ current_content.append(line[1:])
580
+ elif current_file and line.startswith(" "):
581
+ # Context line, keep it as-is (strip leading space)
582
+ current_content.append(line[1:])
583
+ if current_file and current_content:
584
+ files_content.append((current_file, "\n".join(current_content)))
585
+
562
586
  return files_content
563
587
 
564
588
 
589
+ def _git_committable_set(dir_path):
590
+ """Return the set of resolved absolute paths that git tracks or would
591
+ consider committable (tracked + untracked-but-not-ignored) for the repo
592
+ containing `dir_path`, or None if dir_path is not inside a git work tree
593
+ or git is unavailable/errors.
594
+
595
+ Built from `git ls-files --cached --others --exclude-standard`, which lists
596
+ exactly the tracked files plus untracked files that are NOT git-ignored.
597
+ A tracked file is ALWAYS included (even a force-added one that matches an
598
+ ignore rule), so filtering by this set never skips a committable file --
599
+ only ephemeral, git-ignored runtime files (which can never be pushed and
600
+ therefore can't leak a secret through git) are excluded.
601
+ """
602
+ try:
603
+ top = subprocess.run(
604
+ ["git", "rev-parse", "--show-toplevel"],
605
+ cwd=str(dir_path), capture_output=True, text=True, timeout=10,
606
+ )
607
+ if top.returncode != 0:
608
+ return None
609
+ toplevel = Path(top.stdout.strip())
610
+ listing = subprocess.run(
611
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
612
+ cwd=str(toplevel), capture_output=True, text=True, timeout=60,
613
+ )
614
+ if listing.returncode != 0:
615
+ return None
616
+ except Exception:
617
+ return None
618
+
619
+ committable = set()
620
+ for rel in listing.stdout.split("\0"):
621
+ if rel:
622
+ committable.add((toplevel / rel).resolve())
623
+ return committable
624
+
625
+
565
626
  def scan_paths(paths):
566
- """Recursively scan paths (files and directories)."""
627
+ """Recursively scan paths (files and directories).
628
+
629
+ For a DIRECTORY argument inside a git work tree, git-ignored files are
630
+ skipped: a full-tree scan (e.g. CI's `secret_scan.py .`) must not flag
631
+ ephemeral, git-ignored runtime files -- such as state/.ui-session-token --
632
+ that can never be committed or pushed and therefore cannot leak a secret
633
+ through git. Filtering uses the tracked+untracked-not-ignored set from
634
+ `git ls-files`, so a committable file is never skipped. When a directory is
635
+ not inside a git repo (or git errors), NO filtering is applied and every
636
+ file is walked (fail OPEN toward more scanning, never less). Explicitly
637
+ named FILE arguments are always scanned, even if git-ignored.
638
+ """
567
639
  files_to_scan = []
568
640
  for path_str in paths:
569
641
  path = Path(path_str).resolve()
570
642
  if path.is_file():
571
643
  files_to_scan.append(path)
572
644
  elif path.is_dir():
573
- files_to_scan.extend(path.rglob("*"))
645
+ candidates = [p for p in path.rglob("*") if p.is_file()]
646
+ committable = _git_committable_set(path)
647
+ if committable is not None:
648
+ candidates = [p for p in candidates if p.resolve() in committable]
649
+ files_to_scan.extend(candidates)
574
650
  return [p for p in files_to_scan if p.is_file()]
575
651
 
576
652
 
@@ -690,7 +766,13 @@ def main():
690
766
  elif args.history:
691
767
  # History scanning mode: unaffected by the blob-scan fix above, since
692
768
  # it already walks committed diff content via `git log -p`.
693
- history_files = get_history_files(args.repo)
769
+ try:
770
+ history_files = get_history_files(args.repo)
771
+ except GitScanError as e:
772
+ print(f"FATAL: could not scan git history: {e}", file=sys.stderr)
773
+ print("Failing CLOSED: refusing to report CLEAN when git history cannot be read.", file=sys.stderr)
774
+ sys.exit(2)
775
+
694
776
  file_count = len(set(f for f, _ in history_files))
695
777
  for filepath, content in history_files:
696
778
  findings = scan_content(content)
@@ -703,7 +785,12 @@ def main():
703
785
  files = scan_paths(args.paths)
704
786
  file_count = len(files)
705
787
  for filepath in files:
706
- findings = scan_file(filepath)
788
+ try:
789
+ findings = scan_file(filepath)
790
+ except ScanError as e:
791
+ print(f"FATAL: cannot scan file {filepath}: {e}", file=sys.stderr)
792
+ print("Failing CLOSED: refusing to report CLEAN when a file cannot be read.", file=sys.stderr)
793
+ sys.exit(2)
707
794
  for line_num, rule, match_str, is_fatal in findings:
708
795
  all_findings.append((filepath, line_num, rule, match_str, is_fatal))
709
796
  if is_fatal:
@@ -502,6 +502,26 @@ class StatsCounter:
502
502
  data = json.loads(self.json())
503
503
  data["generated_at"] = datetime.now(timezone.utc).isoformat()
504
504
  data["loc"] = self.git.lines_of_code
505
+
506
+ # Add cost economics metrics (requires cost_econ module)
507
+ try:
508
+ from cost_econ import calculate_economics, get_metric_honesty_caveats
509
+ # Infer state directory from repo root or config
510
+ repo_path = Path(self.git.repo_root)
511
+ state_dir = repo_path / "state"
512
+
513
+ economics = calculate_economics(
514
+ repo_root=str(repo_path),
515
+ state_dir=str(state_dir) if state_dir.exists() else None,
516
+ config_file=None
517
+ )
518
+
519
+ data["economics"] = economics
520
+ data["economics_caveats"] = get_metric_honesty_caveats()
521
+ except Exception:
522
+ # Graceful fallback: if cost_econ unavailable, skip economics metrics
523
+ pass
524
+
505
525
  return data
506
526
 
507
527
  def save_stats(self, output_file: str = "stats.json") -> None:
@@ -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()