@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.
- package/CHANGELOG.md +28 -0
- package/README.md +50 -260
- package/bin/cli.js +7 -3
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +4 -3
- package/docs/PORTING.md +166 -0
- package/docs/TEAM-STATE.md +16 -184
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +50 -48
- package/driver/codex_driver.py +59 -12
- package/driver/openai_transport.py +33 -0
- package/driver/wave_bridge.py +9 -1
- package/driver/wave_loop.py +437 -70
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +22 -5
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +4 -4
- package/state_store/__init__.py +2 -1
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/write_api.py +462 -0
- package/tools/cost_ceiling.py +106 -15
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/eod_sweep.py +137 -33
- package/tools/mutation_test.py +130 -8
- package/tools/proposals.mjs +47 -2
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +73 -18
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/wave_preflight.py +296 -29
- package/tools/wave_templates.py +170 -45
- package/ui/collectors.py +81 -0
- package/ui/handler.py +230 -85
- package/ui/sse.py +3 -3
- package/ui/wave_telemetry.py +24 -18
- package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/docs/QUICKSTART.md +0 -80
- package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
package/tools/secret_scan.py
CHANGED
|
@@ -123,12 +123,41 @@ FATAL_RULES = {
|
|
|
123
123
|
"connection_string",
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
# USER-APPROVED EXEMPTION (2026-07-22): redaction-pattern source files.
|
|
127
|
+
# A regex whose PURPOSE is redacting connection strings necessarily contains a
|
|
128
|
+
# connection-string-SHAPED pattern in its source text. connection_string
|
|
129
|
+
# findings in these exact repo-relative paths are downgraded to non-fatal
|
|
130
|
+
# ALLOWED-REDACTION-SOURCE (still reported, never silent). Every other rule
|
|
131
|
+
# stays fully fatal in these files; connection_string stays fatal everywhere
|
|
132
|
+
# else, including pragma'd files. Additions to this set are a security-gate
|
|
133
|
+
# change and require explicit user approval.
|
|
134
|
+
# Residual risk (accepted): matching is by trailing path components, so a
|
|
135
|
+
# nested copy at */bench/sample_transcripts_judgment.py inherits the
|
|
136
|
+
# exemption for this ONE rule; all other rules still apply there.
|
|
137
|
+
REDACTION_SOURCE_FILES = {"bench/sample_transcripts_judgment.py"}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_redaction_source(scan_path):
|
|
141
|
+
"""True when scan_path's trailing components match an exempted file."""
|
|
142
|
+
if not scan_path:
|
|
143
|
+
return False
|
|
144
|
+
parts = str(scan_path).replace("\\", "/").split("/")
|
|
145
|
+
for entry in REDACTION_SOURCE_FILES:
|
|
146
|
+
entry_parts = entry.split("/")
|
|
147
|
+
if parts[-len(entry_parts):] == entry_parts:
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
126
151
|
|
|
127
|
-
def _classify_finding(rule_name, has_file_pragma):
|
|
152
|
+
def _classify_finding(rule_name, has_file_pragma, scan_path=None):
|
|
128
153
|
"""Shared fatal/softened decision, used by every scan_* variant (disk-file,
|
|
129
154
|
blob, large-file-chunked) so the pragma contract can't drift between them.
|
|
130
|
-
FATAL_RULES are always fatal
|
|
131
|
-
|
|
155
|
+
FATAL_RULES are always fatal — except the single user-approved
|
|
156
|
+
connection_string downgrade for REDACTION_SOURCE_FILES (see above).
|
|
157
|
+
SOFTENED_BY_PRAGMA rules are fatal unless the pragma is present.
|
|
158
|
+
Everything else (e.g. env_assignment) is always fatal."""
|
|
159
|
+
if rule_name == "connection_string" and _is_redaction_source(scan_path):
|
|
160
|
+
return False
|
|
132
161
|
if rule_name in FATAL_RULES:
|
|
133
162
|
return True
|
|
134
163
|
if rule_name in SOFTENED_BY_PRAGMA:
|
|
@@ -325,7 +354,7 @@ def scan_file(filepath):
|
|
|
325
354
|
if is_placeholder(match_str):
|
|
326
355
|
continue
|
|
327
356
|
|
|
328
|
-
is_fatal = _classify_finding(rule_name, has_file_pragma)
|
|
357
|
+
is_fatal = _classify_finding(rule_name, has_file_pragma, filepath)
|
|
329
358
|
findings.append((line_num, rule_name, match_str, is_fatal))
|
|
330
359
|
|
|
331
360
|
else:
|
|
@@ -345,7 +374,7 @@ def scan_file(filepath):
|
|
|
345
374
|
if is_placeholder(match_str):
|
|
346
375
|
continue
|
|
347
376
|
|
|
348
|
-
is_fatal = _classify_finding(rule_name, has_file_pragma)
|
|
377
|
+
is_fatal = _classify_finding(rule_name, has_file_pragma, filepath)
|
|
349
378
|
findings.append((line_num, rule_name, match_str, is_fatal))
|
|
350
379
|
|
|
351
380
|
except (OSError, PermissionError, FileNotFoundError, IOError) as e:
|
|
@@ -438,7 +467,7 @@ def scan_blob(label, content_bytes):
|
|
|
438
467
|
match_str = match.group(0)
|
|
439
468
|
if is_placeholder(match_str):
|
|
440
469
|
continue
|
|
441
|
-
is_fatal = _classify_finding(rule_name, has_file_pragma)
|
|
470
|
+
is_fatal = _classify_finding(rule_name, has_file_pragma, label)
|
|
442
471
|
findings.append((line_num, rule_name, match_str, is_fatal))
|
|
443
472
|
|
|
444
473
|
return findings
|
|
@@ -500,6 +529,7 @@ def get_staged_files(repo_path):
|
|
|
500
529
|
cwd=repo_path,
|
|
501
530
|
capture_output=True,
|
|
502
531
|
text=True,
|
|
532
|
+
encoding='utf-8',
|
|
503
533
|
timeout=10,
|
|
504
534
|
)
|
|
505
535
|
except Exception as e:
|
|
@@ -527,6 +557,7 @@ def get_range_files(repo_path, commit_range):
|
|
|
527
557
|
cwd=repo_path,
|
|
528
558
|
capture_output=True,
|
|
529
559
|
text=True,
|
|
560
|
+
encoding='utf-8',
|
|
530
561
|
timeout=10,
|
|
531
562
|
)
|
|
532
563
|
except Exception as e:
|
|
@@ -555,6 +586,8 @@ def get_history_files(repo_path):
|
|
|
555
586
|
cwd=repo_path,
|
|
556
587
|
capture_output=True,
|
|
557
588
|
text=True,
|
|
589
|
+
encoding='utf-8',
|
|
590
|
+
errors='replace',
|
|
558
591
|
timeout=60,
|
|
559
592
|
)
|
|
560
593
|
except Exception as e:
|
|
@@ -602,14 +635,14 @@ def _git_committable_set(dir_path):
|
|
|
602
635
|
try:
|
|
603
636
|
top = subprocess.run(
|
|
604
637
|
["git", "rev-parse", "--show-toplevel"],
|
|
605
|
-
cwd=str(dir_path), capture_output=True, text=True, timeout=10,
|
|
638
|
+
cwd=str(dir_path), capture_output=True, text=True, encoding='utf-8', timeout=10,
|
|
606
639
|
)
|
|
607
640
|
if top.returncode != 0:
|
|
608
641
|
return None
|
|
609
642
|
toplevel = Path(top.stdout.strip())
|
|
610
643
|
listing = subprocess.run(
|
|
611
644
|
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
|
612
|
-
cwd=str(toplevel), capture_output=True, text=True, timeout=60,
|
|
645
|
+
cwd=str(toplevel), capture_output=True, text=True, encoding='utf-8', timeout=60,
|
|
613
646
|
)
|
|
614
647
|
if listing.returncode != 0:
|
|
615
648
|
return None
|
|
@@ -650,8 +683,11 @@ def scan_paths(paths):
|
|
|
650
683
|
return [p for p in files_to_scan if p.is_file()]
|
|
651
684
|
|
|
652
685
|
|
|
653
|
-
def scan_content(content):
|
|
654
|
-
"""Scan raw content for secrets (used by history scanning).
|
|
686
|
+
def scan_content(content, scan_path=None):
|
|
687
|
+
"""Scan raw content for secrets (used by history scanning). scan_path
|
|
688
|
+
(the blob's repo-relative path, when known) feeds the REDACTION_SOURCE_FILES
|
|
689
|
+
exemption so committed redaction-regex source does not turn every future
|
|
690
|
+
--history / prepublish scan permanently red."""
|
|
655
691
|
findings = []
|
|
656
692
|
for line_num, line in enumerate(content.split("\n"), start=1):
|
|
657
693
|
for rule_name, (pattern, flags) in PATTERNS.items():
|
|
@@ -660,7 +696,8 @@ def scan_content(content):
|
|
|
660
696
|
match_str = match.group(0)
|
|
661
697
|
if is_placeholder(match_str):
|
|
662
698
|
continue
|
|
663
|
-
|
|
699
|
+
is_fatal = _classify_finding(rule_name, False, scan_path)
|
|
700
|
+
findings.append((line_num, rule_name, match_str, is_fatal))
|
|
664
701
|
return findings
|
|
665
702
|
|
|
666
703
|
|
|
@@ -703,6 +740,7 @@ def main():
|
|
|
703
740
|
all_findings = []
|
|
704
741
|
fatal_findings = []
|
|
705
742
|
allowed_doc_count = 0
|
|
743
|
+
allowed_redaction_count = 0
|
|
706
744
|
file_count = 0
|
|
707
745
|
|
|
708
746
|
if args.staged:
|
|
@@ -731,7 +769,10 @@ def main():
|
|
|
731
769
|
if is_fatal:
|
|
732
770
|
fatal_findings.append((label, line_num, rule, match_str))
|
|
733
771
|
else:
|
|
734
|
-
|
|
772
|
+
if rule == "connection_string":
|
|
773
|
+
allowed_redaction_count += 1
|
|
774
|
+
else:
|
|
775
|
+
allowed_doc_count += 1
|
|
735
776
|
|
|
736
777
|
elif args.range:
|
|
737
778
|
# Scan the COMMITTED blob at the TIP of the range for each changed
|
|
@@ -761,7 +802,10 @@ def main():
|
|
|
761
802
|
if is_fatal:
|
|
762
803
|
fatal_findings.append((label, line_num, rule, match_str))
|
|
763
804
|
else:
|
|
764
|
-
|
|
805
|
+
if rule == "connection_string":
|
|
806
|
+
allowed_redaction_count += 1
|
|
807
|
+
else:
|
|
808
|
+
allowed_doc_count += 1
|
|
765
809
|
|
|
766
810
|
elif args.history:
|
|
767
811
|
# History scanning mode: unaffected by the blob-scan fix above, since
|
|
@@ -775,7 +819,7 @@ def main():
|
|
|
775
819
|
|
|
776
820
|
file_count = len(set(f for f, _ in history_files))
|
|
777
821
|
for filepath, content in history_files:
|
|
778
|
-
findings = scan_content(content)
|
|
822
|
+
findings = scan_content(content, scan_path=filepath)
|
|
779
823
|
for line_num, rule, match_str, is_fatal in findings:
|
|
780
824
|
all_findings.append((filepath, line_num, rule, match_str, is_fatal))
|
|
781
825
|
if is_fatal:
|
|
@@ -796,7 +840,10 @@ def main():
|
|
|
796
840
|
if is_fatal:
|
|
797
841
|
fatal_findings.append((filepath, line_num, rule, match_str))
|
|
798
842
|
else:
|
|
799
|
-
|
|
843
|
+
if rule == "connection_string":
|
|
844
|
+
allowed_redaction_count += 1
|
|
845
|
+
else:
|
|
846
|
+
allowed_doc_count += 1
|
|
800
847
|
|
|
801
848
|
# Output findings
|
|
802
849
|
for filepath, line_num, rule, match_str, is_fatal in all_findings:
|
|
@@ -804,18 +851,26 @@ def main():
|
|
|
804
851
|
if is_fatal:
|
|
805
852
|
print(f"HIGH {filepath}:{line_num} {rule} ({masked})")
|
|
806
853
|
else:
|
|
807
|
-
|
|
854
|
+
if rule == "connection_string":
|
|
855
|
+
print(f"ALLOWED-REDACTION-SOURCE {filepath}:{line_num} {rule} ({masked})")
|
|
856
|
+
else:
|
|
857
|
+
print(f"ALLOWED-DOC {filepath}:{line_num} {rule} ({masked})")
|
|
808
858
|
|
|
809
859
|
# Summary and exit
|
|
810
860
|
if len(fatal_findings) == 0:
|
|
811
|
-
if allowed_doc_count == 0:
|
|
861
|
+
if allowed_doc_count == 0 and allowed_redaction_count == 0:
|
|
812
862
|
if args.history:
|
|
813
863
|
print(f"CLEAN: git history scanned")
|
|
814
864
|
else:
|
|
815
865
|
print(f"CLEAN: {file_count} files scanned")
|
|
816
866
|
else:
|
|
817
867
|
pragma_file_count = len(set(f for f, _, _, _, is_fatal in all_findings if not is_fatal))
|
|
818
|
-
|
|
868
|
+
parts = []
|
|
869
|
+
if allowed_doc_count:
|
|
870
|
+
parts.append(f"{allowed_doc_count} allowed-doc findings in {pragma_file_count} pragma files")
|
|
871
|
+
if allowed_redaction_count:
|
|
872
|
+
parts.append(f"{allowed_redaction_count} allowed-redaction-source findings")
|
|
873
|
+
print(f"CLEAN: scanned ({'; '.join(parts)})")
|
|
819
874
|
sys.exit(0)
|
|
820
875
|
else:
|
|
821
876
|
print(f"FOUND: {len(fatal_findings)} secret(s)")
|
package/tools/stall_check.py
CHANGED
|
@@ -4,6 +4,7 @@ Automated silent-hang detection for agent transcripts.
|
|
|
4
4
|
|
|
5
5
|
Usage:
|
|
6
6
|
stall_check.py [--transcripts-root DIR] [--threshold-seconds SEC] [--json] [--exit-nonzero-on-stall]
|
|
7
|
+
[--active-from DIR] [--emit-recovery] [--recovery-dir DIR]
|
|
7
8
|
|
|
8
9
|
Options:
|
|
9
10
|
--transcripts-root DIR Root directory to scan for agent-*.jsonl transcripts.
|
|
@@ -12,11 +13,24 @@ Options:
|
|
|
12
13
|
Transcripts older than this are flagged as stalled.
|
|
13
14
|
--json Output as JSON list of {agent_id, age_seconds, stalled, last_mtime}.
|
|
14
15
|
--exit-nonzero-on-stall Exit 1 if any agent is detected as stalled; default exit 0 always.
|
|
16
|
+
--active-from DIR Optional. When set, an agent is ACTIVE only if a matching task/status
|
|
17
|
+
file exists in DIR (named <agent_id>.task or <agent_id>.status).
|
|
18
|
+
STALLED = stale mtime AND active. Default behavior (no flag):
|
|
19
|
+
STALLED = stale mtime only.
|
|
20
|
+
--emit-recovery When set, emit recovery advisory JSON for each STALLED agent.
|
|
21
|
+
Advisory includes: agent, verdict, age_s, suggested_action (list).
|
|
22
|
+
Output as JSON blocks (one per STALLED agent).
|
|
23
|
+
--recovery-dir DIR Optional; only used with --emit-recovery. When set, write one
|
|
24
|
+
recovery-<agent>.json file per STALLED agent to DIR (idempotent).
|
|
25
|
+
No files written when none stalled.
|
|
15
26
|
|
|
16
27
|
Behavior:
|
|
17
28
|
- Walks transcripts-root for files matching agent-*.jsonl.
|
|
18
29
|
- For each file, computes age = now - file mtime (seconds).
|
|
19
|
-
- Reports agents as stalled if age > threshold-seconds.
|
|
30
|
+
- Without --active-from: Reports agents as stalled if age > threshold-seconds (legacy).
|
|
31
|
+
- With --active-from: Reports agents as stalled if age > threshold-seconds AND active file exists.
|
|
32
|
+
- With --emit-recovery: Emits JSON advisory for each STALLED agent (to stdout).
|
|
33
|
+
- With --recovery-dir: Additionally writes recovery-<agent>.json files (idempotent, overwrite).
|
|
20
34
|
- Default output: human-readable table; add --json for structured output.
|
|
21
35
|
- Gracefully reports "no transcripts found" if root is missing or empty.
|
|
22
36
|
- Exit code: 0 always (unless --exit-nonzero-on-stall specified and stalls detected).
|
|
@@ -27,9 +41,50 @@ import os
|
|
|
27
41
|
import time
|
|
28
42
|
import json
|
|
29
43
|
import argparse
|
|
44
|
+
import re
|
|
30
45
|
from pathlib import Path
|
|
31
46
|
|
|
32
47
|
|
|
48
|
+
def sanitize_agent_id(agent_id, filename):
|
|
49
|
+
"""Validate and sanitize agent ID to prevent path traversal (CWE-22).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
agent_id: Extracted agent ID from filename
|
|
53
|
+
filename: Original filename for warning message
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Sanitized agent_id if valid, None if invalid (caller should skip entry)
|
|
57
|
+
"""
|
|
58
|
+
# Allowlist: only alphanumeric, underscore, dash
|
|
59
|
+
if not re.match(r"^[A-Za-z0-9_-]+$", agent_id):
|
|
60
|
+
# Log warning with filename only, never echo raw agent_id into a path
|
|
61
|
+
sys.stderr.write(f"Warning: Skipping transcript with invalid agent ID in file: {filename}\n")
|
|
62
|
+
return None
|
|
63
|
+
return agent_id
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def verify_path_containment(resolved_path, base_dir):
|
|
67
|
+
"""Verify that a path is contained within a base directory (defense-in-depth).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
resolved_path: Resolved absolute path to verify
|
|
71
|
+
base_dir: Base directory that path must be contained in
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if path is safely contained, False if escape attempted
|
|
75
|
+
"""
|
|
76
|
+
# Resolve BOTH sides: an unresolved 8.3 short-form candidate compared
|
|
77
|
+
# against a resolved long-form base raises ValueError and reads as an
|
|
78
|
+
# escape (windows-runner regression: stale+active reported ok).
|
|
79
|
+
resolved_base = Path(base_dir).resolve()
|
|
80
|
+
try:
|
|
81
|
+
Path(resolved_path).resolve().relative_to(resolved_base)
|
|
82
|
+
return True
|
|
83
|
+
except ValueError:
|
|
84
|
+
# Path is outside base_dir
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
33
88
|
def get_transcripts_root():
|
|
34
89
|
"""Resolve transcripts root from env var or default to ~/.claude/projects."""
|
|
35
90
|
if os.environ.get("AESOP_TRANSCRIPTS_ROOT"):
|
|
@@ -38,10 +93,44 @@ def get_transcripts_root():
|
|
|
38
93
|
return Path.home() / ".claude" / "projects"
|
|
39
94
|
|
|
40
95
|
|
|
41
|
-
def
|
|
96
|
+
def is_agent_active(agent_id, active_from_dir):
|
|
97
|
+
"""Check if an agent is active by looking for task/status files.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
agent_id: Agent identifier (e.g., 'abc123') - must be pre-sanitized
|
|
101
|
+
active_from_dir: Directory to scan for <agent_id>.task or <agent_id>.status files
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
True if any matching task/status file exists, False otherwise.
|
|
105
|
+
"""
|
|
106
|
+
if not active_from_dir:
|
|
107
|
+
return None # Flag not provided
|
|
108
|
+
|
|
109
|
+
active_from_path = Path(active_from_dir)
|
|
110
|
+
if not active_from_path.exists():
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
# Check for <agent_id>.task or <agent_id>.status files with containment verification
|
|
114
|
+
for pattern in [f"{agent_id}.task", f"{agent_id}.status"]:
|
|
115
|
+
candidate_path = (active_from_path / pattern)
|
|
116
|
+
# Defense-in-depth: verify path is contained in active_from_path
|
|
117
|
+
if verify_path_containment(candidate_path, active_from_path):
|
|
118
|
+
if candidate_path.exists():
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def scan_transcripts(transcripts_root, threshold_seconds, active_from_dir=None):
|
|
42
125
|
"""Scan transcripts root for agent-*.jsonl files and compute staleness.
|
|
43
126
|
|
|
44
|
-
|
|
127
|
+
Args:
|
|
128
|
+
transcripts_root: Root directory to scan
|
|
129
|
+
threshold_seconds: Staleness threshold in seconds
|
|
130
|
+
active_from_dir: Optional. When set, agent is ACTIVE only if task/status file exists.
|
|
131
|
+
STALLED = stale mtime AND active. Default (None): STALLED = stale mtime only.
|
|
132
|
+
|
|
133
|
+
Returns: list of dicts {agent, transcript, mtime_age_s, verdict, suggested_action, last_mtime (ISO), active}
|
|
45
134
|
"""
|
|
46
135
|
transcripts_root = Path(transcripts_root)
|
|
47
136
|
|
|
@@ -66,16 +155,35 @@ def scan_transcripts(transcripts_root, threshold_seconds):
|
|
|
66
155
|
# Extract agent_id from filename (e.g., agent-abc123.jsonl -> abc123)
|
|
67
156
|
agent_id = jsonl_file.stem.replace("agent-", "")
|
|
68
157
|
|
|
158
|
+
# Sanitize agent_id to prevent path traversal (CWE-22)
|
|
159
|
+
agent_id = sanitize_agent_id(agent_id, str(jsonl_file))
|
|
160
|
+
if agent_id is None:
|
|
161
|
+
# Invalid agent_id, skip this entry
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Check active status if flag is provided
|
|
165
|
+
active = is_agent_active(agent_id, active_from_dir)
|
|
166
|
+
|
|
69
167
|
# Determine verdict
|
|
70
168
|
if age_seconds <= STALE_THRESHOLD:
|
|
71
169
|
verdict = "ok"
|
|
72
170
|
suggested_action = None
|
|
73
171
|
elif age_seconds <= DEAD_THRESHOLD:
|
|
74
|
-
|
|
75
|
-
|
|
172
|
+
# If active_from_dir is set, only stale if also active
|
|
173
|
+
if active_from_dir is not None and not active:
|
|
174
|
+
verdict = "ok"
|
|
175
|
+
suggested_action = None
|
|
176
|
+
else:
|
|
177
|
+
verdict = "stale"
|
|
178
|
+
suggested_action = "monitor for progress or investigate why transcript is stalled"
|
|
76
179
|
else:
|
|
77
|
-
|
|
78
|
-
|
|
180
|
+
# If active_from_dir is set, only dead if also active
|
|
181
|
+
if active_from_dir is not None and not active:
|
|
182
|
+
verdict = "ok"
|
|
183
|
+
suggested_action = None
|
|
184
|
+
else:
|
|
185
|
+
verdict = "dead"
|
|
186
|
+
suggested_action = "investigate immediately; agent may be hung or crashed"
|
|
79
187
|
|
|
80
188
|
results.append({
|
|
81
189
|
"agent": agent_id,
|
|
@@ -84,11 +192,107 @@ def scan_transcripts(transcripts_root, threshold_seconds):
|
|
|
84
192
|
"verdict": verdict,
|
|
85
193
|
"suggested_action": suggested_action,
|
|
86
194
|
"last_mtime": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(mtime)),
|
|
195
|
+
"active": active if active_from_dir is not None else None,
|
|
87
196
|
})
|
|
88
197
|
|
|
89
198
|
return results
|
|
90
199
|
|
|
91
200
|
|
|
201
|
+
def emit_recovery_advisories(results):
|
|
202
|
+
"""Emit recovery advisory JSON blocks for stalled agents.
|
|
203
|
+
|
|
204
|
+
Yields JSON strings (one per stalled agent) with advisory structure:
|
|
205
|
+
{
|
|
206
|
+
"agent": <agent_id>,
|
|
207
|
+
"verdict": <verdict>,
|
|
208
|
+
"age_s": <seconds>,
|
|
209
|
+
"suggested_action": [<ordered list of actions>]
|
|
210
|
+
}
|
|
211
|
+
"""
|
|
212
|
+
if not results:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
stalled_entries = [r for r in results if r["verdict"] in ("stale", "dead")]
|
|
216
|
+
|
|
217
|
+
for entry in stalled_entries:
|
|
218
|
+
# Build ordered list of suggested actions
|
|
219
|
+
actions = []
|
|
220
|
+
|
|
221
|
+
if entry["verdict"] == "stale":
|
|
222
|
+
actions.append("SendMessage resume with scope recap")
|
|
223
|
+
actions.append("TaskStop + relaunch from journal")
|
|
224
|
+
actions.append("inspect transcript tail")
|
|
225
|
+
elif entry["verdict"] == "dead":
|
|
226
|
+
actions.append("TaskStop immediately")
|
|
227
|
+
actions.append("inspect transcript for crash/error")
|
|
228
|
+
actions.append("relaunch from last known-good checkpoint")
|
|
229
|
+
|
|
230
|
+
advisory = {
|
|
231
|
+
"agent": entry["agent"],
|
|
232
|
+
"verdict": entry["verdict"],
|
|
233
|
+
"age_s": entry["mtime_age_s"],
|
|
234
|
+
"suggested_action": actions,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
yield json.dumps(advisory)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def write_recovery_files(results, recovery_dir):
|
|
241
|
+
"""Write recovery-<agent>.json files for each stalled agent (idempotent).
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
results: List of scan results
|
|
245
|
+
recovery_dir: Directory to write recovery files to
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Count of files written
|
|
249
|
+
"""
|
|
250
|
+
if not results or not recovery_dir:
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
recovery_path = Path(recovery_dir)
|
|
254
|
+
recovery_path.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
|
|
256
|
+
files_written = 0
|
|
257
|
+
stalled_entries = [r for r in results if r["verdict"] in ("stale", "dead")]
|
|
258
|
+
|
|
259
|
+
for entry in stalled_entries:
|
|
260
|
+
# Build ordered list of suggested actions
|
|
261
|
+
actions = []
|
|
262
|
+
|
|
263
|
+
if entry["verdict"] == "stale":
|
|
264
|
+
actions.append("SendMessage resume with scope recap")
|
|
265
|
+
actions.append("TaskStop + relaunch from journal")
|
|
266
|
+
actions.append("inspect transcript tail")
|
|
267
|
+
elif entry["verdict"] == "dead":
|
|
268
|
+
actions.append("TaskStop immediately")
|
|
269
|
+
actions.append("inspect transcript for crash/error")
|
|
270
|
+
actions.append("relaunch from last known-good checkpoint")
|
|
271
|
+
|
|
272
|
+
advisory = {
|
|
273
|
+
"agent": entry["agent"],
|
|
274
|
+
"verdict": entry["verdict"],
|
|
275
|
+
"age_s": entry["mtime_age_s"],
|
|
276
|
+
"suggested_action": actions,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
recovery_file = recovery_path / f"recovery-{entry['agent']}.json"
|
|
280
|
+
|
|
281
|
+
# Defense-in-depth: verify recovery_file is contained in recovery_path
|
|
282
|
+
if not verify_path_containment(recovery_file, recovery_path):
|
|
283
|
+
sys.stderr.write(f"Warning: Recovery file path escape detected, skipping: {entry['agent']}\n")
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
recovery_file.write_text(json.dumps(advisory, indent=2), encoding='utf-8')
|
|
288
|
+
files_written += 1
|
|
289
|
+
except Exception as e:
|
|
290
|
+
# Fail-open: log error but continue
|
|
291
|
+
sys.stderr.write(f"Warning: Failed to write {recovery_file}: {e}\n")
|
|
292
|
+
|
|
293
|
+
return files_written
|
|
294
|
+
|
|
295
|
+
|
|
92
296
|
def print_human_table(results):
|
|
93
297
|
"""Print results as a human-readable table."""
|
|
94
298
|
if not results:
|
|
@@ -140,6 +344,21 @@ def main():
|
|
|
140
344
|
action="store_true",
|
|
141
345
|
help="Exit 1 if any agent is stalled (default: always exit 0)",
|
|
142
346
|
)
|
|
347
|
+
parser.add_argument(
|
|
348
|
+
"--active-from",
|
|
349
|
+
default=None,
|
|
350
|
+
help="Optional. Directory to scan for task/status files. Agent is ACTIVE only if file exists.",
|
|
351
|
+
)
|
|
352
|
+
parser.add_argument(
|
|
353
|
+
"--emit-recovery",
|
|
354
|
+
action="store_true",
|
|
355
|
+
help="Emit recovery advisory JSON blocks to stdout for each STALLED agent",
|
|
356
|
+
)
|
|
357
|
+
parser.add_argument(
|
|
358
|
+
"--recovery-dir",
|
|
359
|
+
default=None,
|
|
360
|
+
help="Optional; only used with --emit-recovery. Write recovery-<agent>.json files to this directory",
|
|
361
|
+
)
|
|
143
362
|
|
|
144
363
|
args = parser.parse_args()
|
|
145
364
|
|
|
@@ -147,16 +366,28 @@ def main():
|
|
|
147
366
|
transcripts_root = args.transcripts_root if args.transcripts_root else get_transcripts_root()
|
|
148
367
|
|
|
149
368
|
# Scan transcripts
|
|
150
|
-
results = scan_transcripts(transcripts_root, args.threshold_seconds)
|
|
151
|
-
|
|
152
|
-
#
|
|
153
|
-
if args.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
369
|
+
results = scan_transcripts(transcripts_root, args.threshold_seconds, args.active_from)
|
|
370
|
+
|
|
371
|
+
# Emit recovery advisories if requested
|
|
372
|
+
if args.emit_recovery and results:
|
|
373
|
+
for advisory_json in emit_recovery_advisories(results):
|
|
374
|
+
print(advisory_json)
|
|
375
|
+
|
|
376
|
+
# Write recovery files if requested
|
|
377
|
+
if args.emit_recovery and args.recovery_dir and results:
|
|
378
|
+
write_recovery_files(results, args.recovery_dir)
|
|
379
|
+
|
|
380
|
+
# Output (human-readable or JSON)
|
|
381
|
+
# Note: if --emit-recovery was used, recovery advisories already printed above
|
|
382
|
+
if not args.emit_recovery:
|
|
383
|
+
# Only print human-readable/JSON if we're not emitting recovery advisories
|
|
384
|
+
if args.json:
|
|
385
|
+
print_json_output(results)
|
|
158
386
|
else:
|
|
159
|
-
|
|
387
|
+
if results is None:
|
|
388
|
+
print("no transcripts found")
|
|
389
|
+
else:
|
|
390
|
+
print_human_table(results)
|
|
160
391
|
|
|
161
392
|
# Determine exit code
|
|
162
393
|
exit_code = 0
|