@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
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Wave dispatch collector — live per-agent phase and activity visibility.
4
+
5
+ Surfaces what a wave's workers are doing RIGHT NOW: per-agent phase (dispatch/thinking/tool-use/stall/done),
6
+ last-activity age, and token burn estimates.
7
+
8
+ Data sources:
9
+ - Agent transcripts: recursively scanned from {transcripts_root}/**/agent-*.jsonl (mtime + file size).
10
+ Real layout: ~/.claude/projects/*/subagents/agent-*.jsonl or {session}/subagents/agent-*.jsonl.
11
+ Filters to recent files only (<30 min) to avoid hundreds of stale entries.
12
+ - Orchestrator status: state/orchestrator-status.json (phase info)
13
+ - Workflow journal: state/workflow.journal.jsonl (per-agent state transitions, optional)
14
+
15
+ Degrades to {available:false} when no active workflow.
16
+
17
+ Performance: includes ~5s in-process cache to avoid re-reading transcripts on rapid polls.
18
+ """
19
+ import json
20
+ import sys
21
+ import time
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ import config
26
+
27
+
28
+ # Conservative token estimate: 4.5 bytes per token (empirical average)
29
+ BYTES_PER_TOKEN = 4.5
30
+
31
+ # Module-level cache: (expires_at_epoch, payload_dict). Avoids re-scanning transcripts
32
+ # on rapid polls (typical dashboard: 2-3s interval). TTL matches wave_prs.py pattern.
33
+ # Cache is invalidated if config paths change (important for test isolation).
34
+ _CACHE_TTL_SECONDS = 5.0
35
+ _cache = {"expires": 0.0, "payload": None, "transcripts_root": None}
36
+
37
+
38
+ def _infer_agent_phase_from_transcript(transcript_path):
39
+ """Infer agent phase from transcript tail.
40
+
41
+ Reads the last ~10 lines of an NDJSON transcript to infer phase state.
42
+ Returns one of: 'dispatch', 'thinking', 'tool-use', 'stall', 'done'.
43
+
44
+ Args:
45
+ transcript_path: Path to agent-*.jsonl file
46
+
47
+ Returns:
48
+ str: phase label, or 'unknown' if inference fails
49
+ """
50
+ try:
51
+ if not transcript_path.exists():
52
+ return "unknown"
53
+
54
+ # Read last ~10 lines to infer phase
55
+ with open(transcript_path, 'rb') as f:
56
+ # Seek to end and read backwards to get tail
57
+ f.seek(0, 2)
58
+ file_size = f.tell()
59
+ if file_size == 0:
60
+ return "dispatch" # Empty file, likely just dispatched
61
+
62
+ # Read last ~2KB to capture ~10 lines
63
+ read_size = min(2048, file_size)
64
+ f.seek(max(0, file_size - read_size))
65
+ tail = f.read().decode('utf-8', errors='replace')
66
+
67
+ # Count message types to infer phase
68
+ lines = tail.strip().split('\n')
69
+ message_types = []
70
+ for line in lines:
71
+ if not line.strip():
72
+ continue
73
+ try:
74
+ entry = json.loads(line)
75
+ if isinstance(entry, dict) and 'type' in entry:
76
+ message_types.append(entry['type'].lower())
77
+ except (json.JSONDecodeError, ValueError):
78
+ pass
79
+
80
+ # Phase inference logic:
81
+ # - If last message is user: dispatch
82
+ # - If last message is assistant: thinking or tool-use
83
+ # - If we see [tool_use: ...]: tool-use
84
+ # - If we see [error] or timestamp recent: stall
85
+ # - If multiple completions: done
86
+ if not message_types:
87
+ return "unknown"
88
+
89
+ last_type = message_types[-1] if message_types else "unknown"
90
+
91
+ # Simple heuristic: check for tool_use markers in tail
92
+ tail_lower = tail.lower()
93
+ if '[tool_use:' in tail_lower or 'tool_use' in last_type:
94
+ return "tool-use"
95
+ elif '[error]' in tail_lower or 'error' in tail_lower:
96
+ return "stall"
97
+ elif last_type == 'assistant':
98
+ # Check if we have many completions (indicates done)
99
+ completion_count = tail.count('"type": "assistant"') + tail.count('"type":"assistant"')
100
+ if completion_count >= 3:
101
+ return "done"
102
+ return "thinking"
103
+ elif last_type == 'user':
104
+ return "dispatch"
105
+ else:
106
+ return "thinking"
107
+ except Exception as e:
108
+ print(f"[wave_dispatch] Error inferring phase from {transcript_path}: {e}", file=sys.stderr)
109
+ return "unknown"
110
+
111
+
112
+ def _estimate_tokens_from_file_size(file_path):
113
+ """Estimate tokens from file size.
114
+
115
+ Uses conservative ratio: size (bytes) / 4.5 bytes-per-token.
116
+
117
+ Args:
118
+ file_path: Path to transcript file
119
+
120
+ Returns:
121
+ int: estimated token count, or 0 if file missing
122
+ """
123
+ try:
124
+ if not file_path.exists():
125
+ return 0
126
+ size = file_path.stat().st_size
127
+ return max(0, int(size / BYTES_PER_TOKEN))
128
+ except Exception:
129
+ return 0
130
+
131
+
132
+ def _get_last_activity_age_sec(file_path):
133
+ """Get age of last file modification in seconds.
134
+
135
+ Args:
136
+ file_path: Path to transcript file
137
+
138
+ Returns:
139
+ int: seconds since last mtime, or -1 if file missing
140
+ """
141
+ try:
142
+ if not file_path.exists():
143
+ return -1
144
+ mtime = file_path.stat().st_mtime
145
+ now = time.time()
146
+ age_sec = int(now - mtime)
147
+ return max(0, age_sec)
148
+ except Exception:
149
+ return -1
150
+
151
+
152
+ def get_wave_dispatch(force=False):
153
+ """Get consolidated wave dispatch snapshot.
154
+
155
+ Reads agent transcripts and orchestrator status at call time (not import time)
156
+ to ensure test isolation. Returns per-agent phase, activity age, and token burn.
157
+
158
+ Caches result for ~5s to avoid re-reading transcripts on rapid dashboard polls.
159
+ Pass force=True to bypass cache (mainly for testing).
160
+
161
+ Returns:
162
+ dict: {
163
+ "available": bool,
164
+ "wave_phase": str or None,
165
+ "agents": [
166
+ {
167
+ "id": str,
168
+ "phase": str,
169
+ "last_activity_age_sec": int,
170
+ "token_estimate": int,
171
+ "warnings": [str] (optional)
172
+ }
173
+ ],
174
+ "at": str (ISO 8601)
175
+ }
176
+ """
177
+ # Cache check: if valid cached payload exists and config hasn't changed, return it
178
+ # (Invalidate cache if transcripts_root changed, important for test isolation)
179
+ now_epoch = time.time()
180
+ current_transcripts_root = str(config.TRANSCRIPTS_ROOT)
181
+ cached_transcripts_root = _cache.get("transcripts_root")
182
+
183
+ if (not force and
184
+ _cache["payload"] is not None and
185
+ now_epoch < _cache["expires"] and
186
+ current_transcripts_root == cached_transcripts_root):
187
+ return _cache["payload"]
188
+
189
+ try:
190
+ now = datetime.now(timezone.utc)
191
+ timestamp = now.isoformat(timespec='seconds').replace('+00:00', 'Z')
192
+
193
+ # Try to read orchestrator status to get wave phase
194
+ wave_phase = None
195
+ try:
196
+ orch_status_file = config.ORCH_STATUS_FILE
197
+ if orch_status_file.exists():
198
+ content = orch_status_file.read_text(encoding='utf-8')
199
+ orch_data = json.loads(content)
200
+ wave_phase = orch_data.get("phase")
201
+ except Exception as e:
202
+ print(f"[wave_dispatch] Error reading orchestrator status: {e}", file=sys.stderr)
203
+
204
+ # Find active agent transcripts
205
+ agents = []
206
+ transcripts_root = config.TRANSCRIPTS_ROOT
207
+
208
+ try:
209
+ # Scan recursively for agent-*.jsonl files matching real Claude Code layout:
210
+ # ~/.claude/projects/*/subagents/agent-*.jsonl or {session}/subagents/agent-*.jsonl
211
+ # Uses same pattern as agents.py to ensure consistency (ui/agents.py:482)
212
+ if transcripts_root.exists():
213
+ for transcript_file in transcripts_root.glob("**/agent-*.jsonl"):
214
+ # Filter to recent files only (<30 min) to avoid hundreds of stale entries
215
+ age_sec = _get_last_activity_age_sec(transcript_file)
216
+ if age_sec < 0: # File missing or error reading
217
+ continue
218
+ if age_sec > 1800: # 30 minutes; skip stale transcripts
219
+ continue
220
+
221
+ agent_id = transcript_file.stem # e.g., "agent-12345"
222
+ if agent_id.startswith("agent-"):
223
+ # Remove "agent-" prefix for display
224
+ display_id = agent_id[6:] # "12345"
225
+
226
+ phase = _infer_agent_phase_from_transcript(transcript_file)
227
+ tokens = _estimate_tokens_from_file_size(transcript_file)
228
+
229
+ # Build warnings
230
+ warnings = []
231
+ if age_sec > 300: # 5 minutes
232
+ warnings.append("inactive >5min")
233
+ if age_sec > 600: # 10 minutes
234
+ warnings.append("stalled >10min")
235
+
236
+ agent_entry = {
237
+ "id": display_id,
238
+ "phase": phase,
239
+ "last_activity_age_sec": age_sec,
240
+ "token_estimate": tokens,
241
+ }
242
+ if warnings:
243
+ agent_entry["warnings"] = warnings
244
+
245
+ agents.append(agent_entry)
246
+ except Exception as e:
247
+ print(f"[wave_dispatch] Error scanning transcripts: {e}", file=sys.stderr)
248
+
249
+ # Determine availability: if no agents found, mark as unavailable
250
+ available = len(agents) > 0
251
+
252
+ payload = {
253
+ "available": available,
254
+ "wave_phase": wave_phase,
255
+ "agents": agents,
256
+ "at": timestamp
257
+ }
258
+
259
+ # Cache this payload for ~5s to avoid re-reading transcripts on rapid polls
260
+ _cache["payload"] = payload
261
+ _cache["expires"] = now_epoch + _CACHE_TTL_SECONDS
262
+ _cache["transcripts_root"] = str(config.TRANSCRIPTS_ROOT)
263
+
264
+ return payload
265
+ except Exception as e:
266
+ print(f"[wave_dispatch] Uncaught error: {e}", file=sys.stderr)
267
+ payload = {
268
+ "available": False,
269
+ "wave_phase": None,
270
+ "agents": [],
271
+ "error": "Internal error",
272
+ "at": datetime.now(timezone.utc).isoformat(timespec='seconds').replace('+00:00', 'Z')
273
+ }
274
+
275
+ # Cache error response too (so we don't retry immediately)
276
+ _cache["payload"] = payload
277
+ _cache["expires"] = now_epoch + _CACHE_TTL_SECONDS
278
+ _cache["transcripts_root"] = str(config.TRANSCRIPTS_ROOT)
279
+
280
+ return payload
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env python3
2
+ """Wave PR failure drill-down collector — CI job logs and failure details.
3
+
4
+ Provides structured access to GitHub CI job logs and failure details for a given
5
+ PR number. When the Wave PR Board shows a red PR row, the UI can drill down via
6
+ this endpoint to see which checks failed, their logs, and stderr excerpts.
7
+
8
+ Backend:
9
+ - Shells `gh run view --json jobs` to list jobs for the latest run on a PR branch
10
+ - For failing jobs, shells `gh api repos/{owner}/{repo}/actions/jobs/{id}/logs`
11
+ to fetch the full log, extracts ~100-line tail excerpt
12
+ - 5-second cache per PR to avoid hammering GitHub API
13
+ - Degrades to {available:false, error:...} when gh is missing/unauthed (never 500)
14
+ - Honors AESOP_GH_BIN override; all text uses encoding='utf-8', errors='replace'
15
+
16
+ Response shape when available:
17
+ {
18
+ "available": true,
19
+ "error": null,
20
+ "pr_number": <int>,
21
+ "branch": "<str>",
22
+ "latest_run": {
23
+ "id": "<str>",
24
+ "name": "<str>",
25
+ "status": "completed" | "in_progress" | "queued",
26
+ "conclusion": "success" | "failure" | "cancelled" | "timed_out" | null,
27
+ "url": "<str>"
28
+ },
29
+ "jobs": [
30
+ {
31
+ "id": <int>,
32
+ "name": "<str>",
33
+ "status": "completed" | "in_progress" | "queued",
34
+ "conclusion": "success" | "failure" | "cancelled" | "timed_out" | null,
35
+ "url": "<str>",
36
+ "log_excerpt": "<str>" | null # ~100 lines, tail-truncated; null if fetch fails
37
+ },
38
+ ...
39
+ ]
40
+ }
41
+
42
+ When gh is missing/unauthed:
43
+ {
44
+ "available": false,
45
+ "error": "<human reason>",
46
+ "pr_number": <int>,
47
+ "branch": "",
48
+ "latest_run": null,
49
+ "jobs": []
50
+ }
51
+ """
52
+ import json
53
+ import os
54
+ import re
55
+ import subprocess
56
+ import sys
57
+ import time
58
+ from pathlib import Path
59
+ from typing import Any, Dict, List, Optional, Tuple
60
+
61
+ import config
62
+
63
+ _CACHE_TTL_SECONDS = 5.0
64
+ _SUBPROCESS_TIMEOUT_SECONDS = 12
65
+ _LOG_EXCERPT_LINES = 100
66
+
67
+ # Module-level cache: {pr_number: (expires_at_epoch, payload_dict)}
68
+ _cache: Dict[int, Tuple[float, Dict[str, Any]]] = {}
69
+
70
+
71
+ def _gh_bin() -> str:
72
+ """Path to the GitHub CLI binary.
73
+
74
+ Defaults to `gh` (resolved on PATH). Override with AESOP_GH_BIN to point at
75
+ a specific install or a wrapper — read at call time so it stays live.
76
+ """
77
+ return os.environ.get("AESOP_GH_BIN", "gh")
78
+
79
+
80
+ def _run(cmd: List[str]) -> Tuple[bool, str, str]:
81
+ """Run a subprocess, return (ok, stdout, stderr).
82
+
83
+ ok is False on a missing binary, a non-zero exit, or a timeout. Text is
84
+ decoded utf-8 with errors='replace' so undecodable bytes never raise.
85
+ """
86
+ try:
87
+ proc = subprocess.run(
88
+ cmd,
89
+ cwd=str(config.AESOP_ROOT),
90
+ capture_output=True,
91
+ text=True,
92
+ encoding="utf-8",
93
+ errors="replace",
94
+ timeout=_SUBPROCESS_TIMEOUT_SECONDS,
95
+ )
96
+ except FileNotFoundError:
97
+ return False, "", f"{cmd[0]}: command not found"
98
+ except subprocess.TimeoutExpired:
99
+ return False, "", f"{cmd[0]}: timed out"
100
+ except OSError as e:
101
+ return False, "", f"{cmd[0]}: {e}"
102
+ return proc.returncode == 0, proc.stdout or "", proc.stderr or ""
103
+
104
+
105
+ def _get_branch_for_pr(pr_number: int) -> Optional[str]:
106
+ """Get the branch name for a PR number via gh pr view.
107
+
108
+ Returns the headRefName (branch) for the PR, or None if lookup fails.
109
+ """
110
+ ok, out, _err = _run([
111
+ _gh_bin(), "pr", "view", str(pr_number),
112
+ "--json", "headRefName"
113
+ ])
114
+ if not ok or not out.strip():
115
+ return None
116
+ try:
117
+ data = json.loads(out)
118
+ return data.get("headRefName")
119
+ except (ValueError, TypeError):
120
+ return None
121
+
122
+
123
+ def _get_latest_run(branch: str) -> Optional[Dict[str, Any]]:
124
+ """Get the latest workflow run for a branch.
125
+
126
+ Shells: gh run list --branch <branch> --limit 1 --json id,name,status,conclusion,url
127
+ Returns a run object or None if lookup fails.
128
+ """
129
+ ok, out, _err = _run([
130
+ _gh_bin(), "run", "list", "--branch", branch,
131
+ "--limit", "1",
132
+ "--json", "id,name,status,conclusion,url"
133
+ ])
134
+ if not ok or not out.strip():
135
+ return None
136
+ try:
137
+ data = json.loads(out)
138
+ return data[0] if data else None
139
+ except (ValueError, TypeError, IndexError):
140
+ return None
141
+
142
+
143
+ def _get_jobs_for_run(run_id: str) -> List[Dict[str, Any]]:
144
+ """Get all jobs in a run.
145
+
146
+ Shells: gh run view <run_id> --json jobs --json id,name,status,conclusion,url
147
+ Returns a list of job objects, or empty list if lookup fails.
148
+ """
149
+ ok, out, _err = _run([
150
+ _gh_bin(), "run", "view", run_id,
151
+ "--json", "jobs"
152
+ ])
153
+ if not ok or not out.strip():
154
+ return []
155
+ try:
156
+ data = json.loads(out)
157
+ # data should be {"jobs": [{...}, ...]}
158
+ return data.get("jobs", []) if isinstance(data, dict) else data if isinstance(data, list) else []
159
+ except (ValueError, TypeError):
160
+ return []
161
+
162
+
163
+ def _get_job_log_excerpt(job_id: int) -> Optional[str]:
164
+ """Fetch the full log for a job and extract ~100-line tail excerpt.
165
+
166
+ Shells: gh api repos/{owner}/{repo}/actions/jobs/{job_id}/logs
167
+ (Note: gh api auto-fills {owner}/{repo} when run inside a repo.)
168
+ Extracts the last _LOG_EXCERPT_LINES lines as a tail, decompresses if gzipped.
169
+
170
+ Returns the tail excerpt (plain text, utf-8 with errors='replace'), or None if fetch fails.
171
+ """
172
+ # Determine owner/repo from the current repo (gh api can infer them)
173
+ ok, repo_info, _err = _run([
174
+ "git", "remote", "get-url", "origin"
175
+ ])
176
+ if not ok:
177
+ return None
178
+
179
+ # Try to extract owner/repo from the remote URL
180
+ # Supports: https://github.com/owner/repo.git or git@github.com:owner/repo.git
181
+ match = re.search(r'(?:github\.com[:/]|git@github\.com:)([^/]+)/([^/.]+)', repo_info)
182
+ if not match:
183
+ return None
184
+
185
+ owner, repo = match.group(1), match.group(2)
186
+
187
+ # Fetch log via gh api
188
+ api_path = f"repos/{owner}/{repo}/actions/jobs/{job_id}/logs"
189
+ ok, out, _err = _run([
190
+ _gh_bin(), "api", api_path
191
+ ])
192
+ if not ok or not out.strip():
193
+ return None
194
+
195
+ # Split into lines and take the tail
196
+ lines = out.splitlines()
197
+ tail_lines = lines[-_LOG_EXCERPT_LINES:] if lines else []
198
+ return "\n".join(tail_lines) if tail_lines else None
199
+
200
+
201
+ def _get_failure_details(pr_number: int) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]], List[Dict[str, Any]]]:
202
+ """Collect failure details for a PR.
203
+
204
+ Returns: (available, error, latest_run, jobs)
205
+ - available: bool — gh is working and authenticated
206
+ - error: str | None — human reason if available is False
207
+ - latest_run: run object or None
208
+ - jobs: list of job objects with log_excerpt fields
209
+ """
210
+ # Get the branch for the PR
211
+ branch = _get_branch_for_pr(pr_number)
212
+ if not branch:
213
+ return False, f"Could not find branch for PR #{pr_number}", None, []
214
+
215
+ # Get the latest run for the branch
216
+ run = _get_latest_run(branch)
217
+ if not run:
218
+ # Could mean: no runs at all, or gh failure
219
+ # Assume success (no failure data to show) rather than error
220
+ return True, None, None, []
221
+
222
+ # Get all jobs in the run
223
+ jobs = _get_jobs_for_run(run["id"])
224
+
225
+ # For each job, fetch log excerpt if it failed
226
+ for job in jobs:
227
+ job_id = job.get("id")
228
+ if job_id and job.get("conclusion") == "failure":
229
+ excerpt = _get_job_log_excerpt(job_id)
230
+ job["log_excerpt"] = excerpt
231
+ else:
232
+ job["log_excerpt"] = None
233
+
234
+ return True, None, run, jobs
235
+
236
+
237
+ def get_wave_failure(pr_number: int, force: bool = False) -> Dict[str, Any]:
238
+ """Get consolidated failure drill-down data for a PR.
239
+
240
+ Shape:
241
+ {
242
+ "available": bool,
243
+ "error": str | None,
244
+ "pr_number": int,
245
+ "branch": str,
246
+ "latest_run": {...} | null,
247
+ "jobs": [{id, name, status, conclusion, url, log_excerpt}, ...]
248
+ }
249
+
250
+ Cached for _CACHE_TTL_SECONDS so rapid polls don't re-run gh. Never raises.
251
+ """
252
+ now = time.time()
253
+ if not force and pr_number in _cache:
254
+ expires, payload = _cache[pr_number]
255
+ if now < expires:
256
+ return payload
257
+
258
+ try:
259
+ available, error, run, jobs = _get_failure_details(pr_number)
260
+
261
+ # Get branch name for the response
262
+ branch = ""
263
+ if run:
264
+ # Try to infer branch from run or PR
265
+ branch_name = _get_branch_for_pr(pr_number)
266
+ branch = branch_name or ""
267
+
268
+ payload = {
269
+ "available": available,
270
+ "error": error,
271
+ "pr_number": pr_number,
272
+ "branch": branch,
273
+ "latest_run": run,
274
+ "jobs": jobs,
275
+ }
276
+ except Exception as e:
277
+ print(f"[wave_failure] Uncaught error: {e}", file=sys.stderr)
278
+ payload = {
279
+ "available": False,
280
+ "error": f"Internal error: {e}",
281
+ "pr_number": pr_number,
282
+ "branch": "",
283
+ "latest_run": None,
284
+ "jobs": [],
285
+ }
286
+
287
+ _cache[pr_number] = (now + _CACHE_TTL_SECONDS, payload)
288
+ return payload