@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.
- package/CHANGELOG.md +89 -2
- package/README.md +63 -12
- package/bin/cli.js +164 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/INSTALL.md +70 -1
- package/docs/QUICKSTART.md +80 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +540 -0
- package/driver/CLAUDE.md +148 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +626 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +146 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +246 -0
- package/driver/wave_loop.py +1041 -0
- package/hooks/pre-push-policy.sh +109 -28
- package/mcp/server.mjs +320 -4
- package/package.json +23 -15
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +2 -1
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/store.py +185 -73
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +63 -31
- package/tools/cost_econ.py +480 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +58 -0
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +401 -0
- package/tools/portability_check.py +206 -0
- package/tools/reconcile.py +7 -4
- package/tools/secret_scan.py +137 -50
- package/tools/self_stats.py +20 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +512 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +215 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +0 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +183 -0
- package/ui/quality_scorecard.py +232 -0
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +377 -0
- package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
- package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
package/tools/secret_scan.py
CHANGED
|
@@ -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=
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
for
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
320
|
+
matches = re.finditer(pattern, chunk_line, flags)
|
|
321
|
+
for match in matches:
|
|
322
|
+
match_str = match.group(0)
|
|
306
323
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
324
|
+
# Skip if it's a placeholder
|
|
325
|
+
if is_placeholder(match_str):
|
|
326
|
+
continue
|
|
310
327
|
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
339
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
package/tools/self_stats.py
CHANGED
|
@@ -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()
|