@matt82198/aesop 0.2.0 → 0.3.2

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 (46) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +60 -263
  3. package/bin/cli.js +7 -3
  4. package/daemons/install-tasks.ps1 +193 -0
  5. package/daemons/run-hidden.vbs +39 -0
  6. package/docs/HOOK-INSTALL.md +15 -56
  7. package/docs/INSTALL.md +48 -3
  8. package/docs/PORTING.md +166 -0
  9. package/docs/TEAM-STATE.md +16 -184
  10. package/docs/reproduce.md +33 -3
  11. package/driver/CLAUDE.md +50 -48
  12. package/driver/codex_driver.py +59 -12
  13. package/driver/openai_transport.py +33 -0
  14. package/driver/wave_bridge.py +9 -1
  15. package/driver/wave_loop.py +437 -70
  16. package/driver/wave_scheduler.py +890 -0
  17. package/hooks/pre-push-policy.sh +22 -5
  18. package/monitor/collect-signals.mjs +69 -0
  19. package/package.json +6 -4
  20. package/state_store/__init__.py +2 -1
  21. package/state_store/projections.py +63 -0
  22. package/state_store/read_api.py +156 -0
  23. package/state_store/write_api.py +462 -0
  24. package/tools/cost_ceiling.py +106 -15
  25. package/tools/cost_projection.py +559 -0
  26. package/tools/crossos_drift.py +394 -0
  27. package/tools/eod_sweep.py +137 -33
  28. package/tools/mutation_test.py +130 -8
  29. package/tools/proposals.mjs +47 -2
  30. package/tools/reproduce.js +405 -0
  31. package/tools/secret_scan.py +73 -18
  32. package/tools/stall_check.py +247 -16
  33. package/tools/stateapi_lint.py +325 -0
  34. package/tools/test_battery.py +173 -0
  35. package/tools/verify_cost_panel.py +345 -0
  36. package/tools/wave_preflight.py +296 -29
  37. package/tools/wave_templates.py +170 -45
  38. package/ui/collectors.py +81 -0
  39. package/ui/handler.py +230 -85
  40. package/ui/sse.py +3 -3
  41. package/ui/wave_telemetry.py +24 -18
  42. package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
  43. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  44. package/ui/web/dist/index.html +2 -2
  45. package/docs/QUICKSTART.md +0 -80
  46. package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
@@ -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. SOFTENED_BY_PRAGMA rules are fatal unless the
131
- pragma is present. Everything else (e.g. env_assignment) is always fatal."""
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
- findings.append((line_num, rule_name, match_str, True))
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
- allowed_doc_count += 1
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
- allowed_doc_count += 1
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
- allowed_doc_count += 1
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
- print(f"ALLOWED-DOC {filepath}:{line_num} {rule} ({masked})")
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
- print(f"CLEAN: scanned ({allowed_doc_count} allowed-doc findings in {pragma_file_count} pragma files)")
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)")
@@ -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 scan_transcripts(transcripts_root, threshold_seconds):
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
- Returns: list of dicts {agent, transcript, mtime_age_s, verdict, suggested_action, last_mtime (ISO)}
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
- verdict = "stale"
75
- suggested_action = "monitor for progress or investigate why transcript is stalled"
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
- verdict = "dead"
78
- suggested_action = "investigate immediately; agent may be hung or crashed"
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
- # Output
153
- if args.json:
154
- print_json_output(results)
155
- else:
156
- if results is None:
157
- print("no transcripts found")
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
- print_human_table(results)
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