@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
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Wave backlog risk analyzer — assess risk for backlog items pre-wave.
|
|
4
|
+
|
|
5
|
+
Analyzes tracker backlog items and correlates with git history to compute
|
|
6
|
+
per-item risk assessments. Uses deterministic heuristics only:
|
|
7
|
+
- Past failure counts (fix-forward commits by domain)
|
|
8
|
+
- Repair frequency for similar-file patterns
|
|
9
|
+
- Item size proxies
|
|
10
|
+
|
|
11
|
+
Output: JSON with {slug, risk_level, estimated_retries, justification}
|
|
12
|
+
- risk_level: high/medium/low/unknown
|
|
13
|
+
- estimated_retries: integer or null
|
|
14
|
+
- justification: human-readable explanation
|
|
15
|
+
|
|
16
|
+
Exit: always 0 (warn-level only; never blocks preflight)
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python tools/wave_backlog_analyzer.py [--root REPO_ROOT] [--state-root STATE_ROOT] [--json]
|
|
20
|
+
|
|
21
|
+
Arguments:
|
|
22
|
+
--root REPO_ROOT: repository root directory (default: cwd)
|
|
23
|
+
--state-root STATE_ROOT: state directory (default: REPO_ROOT/state or ./state)
|
|
24
|
+
--json: output in JSON format (default: text)
|
|
25
|
+
|
|
26
|
+
Environment:
|
|
27
|
+
AESOP_STATE_ROOT: state dir (takes precedence over --state-root argument)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from datetime import datetime, timedelta
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
from common import get_state_dir
|
|
40
|
+
except ImportError:
|
|
41
|
+
from tools.common import get_state_dir
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config(root_dir=None):
|
|
45
|
+
"""Load aesop.config.json from root, return dict (or {} if absent/bad)."""
|
|
46
|
+
if root_dir is None:
|
|
47
|
+
root_dir = Path.cwd()
|
|
48
|
+
else:
|
|
49
|
+
root_dir = Path(root_dir)
|
|
50
|
+
|
|
51
|
+
config_file = root_dir / "aesop.config.json"
|
|
52
|
+
if not config_file.exists():
|
|
53
|
+
return {}
|
|
54
|
+
try:
|
|
55
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
56
|
+
return json.load(f)
|
|
57
|
+
except Exception:
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_state_dir(root_dir=None, config=None):
|
|
62
|
+
"""Resolve state dir: AESOP_STATE_ROOT env > config state_root > ./state."""
|
|
63
|
+
if os.environ.get("AESOP_STATE_ROOT"):
|
|
64
|
+
return Path(os.environ["AESOP_STATE_ROOT"])
|
|
65
|
+
|
|
66
|
+
if root_dir is None:
|
|
67
|
+
root_dir = Path.cwd()
|
|
68
|
+
else:
|
|
69
|
+
root_dir = Path(root_dir)
|
|
70
|
+
|
|
71
|
+
if config is None:
|
|
72
|
+
config = load_config(root_dir)
|
|
73
|
+
|
|
74
|
+
state_root = config.get("state_root") if isinstance(config, dict) else None
|
|
75
|
+
if state_root:
|
|
76
|
+
p = Path(state_root).expanduser()
|
|
77
|
+
if not p.is_absolute():
|
|
78
|
+
p = root_dir / p
|
|
79
|
+
return p
|
|
80
|
+
|
|
81
|
+
return root_dir / "state"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_tracker_json(state_dir):
|
|
85
|
+
"""Load state/tracker.json.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
list of items, or empty list if not found or invalid
|
|
89
|
+
"""
|
|
90
|
+
tracker_path = Path(state_dir) / "tracker.json"
|
|
91
|
+
if not tracker_path.exists():
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
with open(tracker_path, "r", encoding="utf-8") as f:
|
|
96
|
+
data = json.load(f)
|
|
97
|
+
if isinstance(data, dict) and "items" in data:
|
|
98
|
+
items = data["items"]
|
|
99
|
+
if isinstance(items, list):
|
|
100
|
+
return items
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_domain_from_item(item):
|
|
108
|
+
"""Extract domain hint from item (title, description, slug).
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
str: domain name or None
|
|
112
|
+
"""
|
|
113
|
+
if not isinstance(item, dict):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
# Try description or title first (more reliable)
|
|
117
|
+
for field in ["description", "title"]:
|
|
118
|
+
text = item.get(field, "")
|
|
119
|
+
if isinstance(text, str) and text:
|
|
120
|
+
# Look for path patterns: tools/, state_store/, ui/, etc.
|
|
121
|
+
match = re.search(r"\b([a-z_]+)/", text)
|
|
122
|
+
if match:
|
|
123
|
+
return match.group(1)
|
|
124
|
+
# Look for domain keywords (with flexible hyphen/underscore matching)
|
|
125
|
+
for domain in ["tools", "state_store", "state-store", "ui", "daemons", "monitor", "mcp", "hooks", "driver", "bin"]:
|
|
126
|
+
if domain in text.lower():
|
|
127
|
+
return domain.replace("-", "_")
|
|
128
|
+
|
|
129
|
+
# Try to extract from slug: tools-001 -> tools, state-store-fix -> state_store
|
|
130
|
+
# Known domain patterns (avoid false positives)
|
|
131
|
+
known_domains = ["tools", "state_store", "ui", "daemons", "monitor", "mcp", "hooks", "driver", "bin", "state"]
|
|
132
|
+
slug = item.get("slug", "")
|
|
133
|
+
if slug:
|
|
134
|
+
slug_lower = slug.lower()
|
|
135
|
+
for domain in known_domains:
|
|
136
|
+
if slug_lower.startswith(domain):
|
|
137
|
+
return domain
|
|
138
|
+
# Fallback: extract prefix before any hyphen and normalize
|
|
139
|
+
match = re.match(r"^([a-z_]+)", slug)
|
|
140
|
+
if match:
|
|
141
|
+
return match.group(1)
|
|
142
|
+
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_commits_since(repo_path, days=30):
|
|
147
|
+
"""Get all commits in the last N days.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
repo_path: path to git repo
|
|
151
|
+
days: number of days to look back
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
list of (commit_hash, subject) tuples
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
since_date = (datetime.now() - timedelta(days=days)).isoformat()
|
|
158
|
+
result = subprocess.run(
|
|
159
|
+
["git", "-C", str(repo_path), "log", "--all", f"--since={since_date}", "--format=%H%n%s%n--END--"],
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
timeout=10,
|
|
163
|
+
)
|
|
164
|
+
if result.returncode != 0:
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
commits = []
|
|
168
|
+
lines = result.stdout.split("\n")
|
|
169
|
+
i = 0
|
|
170
|
+
while i < len(lines):
|
|
171
|
+
if i + 1 < len(lines) and lines[i + 1] != "--END--":
|
|
172
|
+
commit_hash = lines[i]
|
|
173
|
+
subject = lines[i + 1]
|
|
174
|
+
commits.append((commit_hash, subject))
|
|
175
|
+
i += 3 # hash, subject, --END--
|
|
176
|
+
else:
|
|
177
|
+
i += 1
|
|
178
|
+
return commits
|
|
179
|
+
except Exception:
|
|
180
|
+
return []
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_files_changed_in_commit(repo_path, commit_hash):
|
|
184
|
+
"""Get list of files changed in a commit.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
list of file paths
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
result = subprocess.run(
|
|
191
|
+
["git", "-C", str(repo_path), "show", "--name-only", "--format=", commit_hash],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
text=True,
|
|
194
|
+
timeout=5,
|
|
195
|
+
)
|
|
196
|
+
if result.returncode == 0:
|
|
197
|
+
return [f.strip() for f in result.stdout.split("\n") if f.strip()]
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def is_fixforward_commit(subject):
|
|
204
|
+
"""Check if commit subject matches fix-forward pattern."""
|
|
205
|
+
patterns = [
|
|
206
|
+
r"fix-forward",
|
|
207
|
+
r"hotfix",
|
|
208
|
+
r"fix\s*\(\s*ci\s*\)",
|
|
209
|
+
r"repair",
|
|
210
|
+
]
|
|
211
|
+
combined = "|".join(f"({p})" for p in patterns)
|
|
212
|
+
return bool(re.search(combined, subject, re.IGNORECASE))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def analyze_domain_history(repo_path, domain, commits, lookback_days=30):
|
|
216
|
+
"""Analyze commit history for a domain.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
dict: {
|
|
220
|
+
feature_commits: int,
|
|
221
|
+
fixforward_commits: int,
|
|
222
|
+
repair_frequency: float (0.0-1.0),
|
|
223
|
+
related_files: set of related file paths
|
|
224
|
+
}
|
|
225
|
+
"""
|
|
226
|
+
domain_pattern = re.compile(rf"\b{re.escape(domain)}\b")
|
|
227
|
+
|
|
228
|
+
feature_commits = 0
|
|
229
|
+
fixforward_commits = 0
|
|
230
|
+
related_files = set()
|
|
231
|
+
|
|
232
|
+
for commit_hash, subject in commits:
|
|
233
|
+
if domain_pattern.search(subject) or domain_pattern.search(commit_hash):
|
|
234
|
+
if is_fixforward_commit(subject):
|
|
235
|
+
fixforward_commits += 1
|
|
236
|
+
else:
|
|
237
|
+
feature_commits += 1
|
|
238
|
+
|
|
239
|
+
# Track files changed in this commit
|
|
240
|
+
files = get_files_changed_in_commit(repo_path, commit_hash)
|
|
241
|
+
for f in files:
|
|
242
|
+
if f.startswith(domain + "/") or domain in f:
|
|
243
|
+
related_files.add(f)
|
|
244
|
+
|
|
245
|
+
total_domain_commits = feature_commits + fixforward_commits
|
|
246
|
+
repair_frequency = (
|
|
247
|
+
fixforward_commits / feature_commits
|
|
248
|
+
if feature_commits > 0
|
|
249
|
+
else (0.5 if fixforward_commits > 0 else 0.0)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"feature_commits": feature_commits,
|
|
254
|
+
"fixforward_commits": fixforward_commits,
|
|
255
|
+
"repair_frequency": repair_frequency,
|
|
256
|
+
"related_files": related_files,
|
|
257
|
+
"total_commits": total_domain_commits,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def compute_risk_level(domain, history, item):
|
|
262
|
+
"""Compute risk level based on history.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
str: high/medium/low/unknown
|
|
266
|
+
"""
|
|
267
|
+
if history["total_commits"] == 0:
|
|
268
|
+
return "unknown"
|
|
269
|
+
|
|
270
|
+
# Heuristics:
|
|
271
|
+
# - repair_frequency > 0.5 = high risk (more fixes than features)
|
|
272
|
+
# - 0.3-0.5 = medium risk
|
|
273
|
+
# - < 0.3 = low risk
|
|
274
|
+
# - feature_commits >= 5 increases risk slightly
|
|
275
|
+
|
|
276
|
+
repair_freq = history["repair_frequency"]
|
|
277
|
+
feature_count = history["feature_commits"]
|
|
278
|
+
|
|
279
|
+
if repair_freq > 0.5:
|
|
280
|
+
return "high"
|
|
281
|
+
elif repair_freq > 0.3 or feature_count > 5:
|
|
282
|
+
return "medium"
|
|
283
|
+
else:
|
|
284
|
+
return "low"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def compute_estimated_retries(history):
|
|
288
|
+
"""Compute estimated retries based on repair frequency.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
int or None
|
|
292
|
+
"""
|
|
293
|
+
if history["total_commits"] == 0:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
# Heuristics:
|
|
297
|
+
# - No history: None
|
|
298
|
+
# - repair_frequency * max(feature_commits / 2, 1) = estimated retries
|
|
299
|
+
freq = history["repair_frequency"]
|
|
300
|
+
features = history["feature_commits"]
|
|
301
|
+
|
|
302
|
+
if features == 0:
|
|
303
|
+
return None if freq == 0 else 1
|
|
304
|
+
|
|
305
|
+
estimated = round(freq * max(features / 2, 1))
|
|
306
|
+
return max(0, estimated)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def build_justification(domain, item, history):
|
|
310
|
+
"""Build a human-readable justification for the risk assessment.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
str
|
|
314
|
+
"""
|
|
315
|
+
if history["total_commits"] == 0:
|
|
316
|
+
return "No history found for this domain in the last 30 days."
|
|
317
|
+
|
|
318
|
+
parts = []
|
|
319
|
+
|
|
320
|
+
# Mention domain
|
|
321
|
+
parts.append(f"Domain: {domain}")
|
|
322
|
+
|
|
323
|
+
# Mention commit counts
|
|
324
|
+
features = history["feature_commits"]
|
|
325
|
+
fixes = history["fixforward_commits"]
|
|
326
|
+
parts.append(f"Recent commits: {features} features, {fixes} fixes")
|
|
327
|
+
|
|
328
|
+
# Mention repair frequency
|
|
329
|
+
freq = history["repair_frequency"]
|
|
330
|
+
if freq > 0.5:
|
|
331
|
+
parts.append(f"High repair frequency ({freq:.1%})")
|
|
332
|
+
elif freq > 0.3:
|
|
333
|
+
parts.append(f"Moderate repair frequency ({freq:.1%})")
|
|
334
|
+
else:
|
|
335
|
+
parts.append(f"Low repair frequency ({freq:.1%})")
|
|
336
|
+
|
|
337
|
+
# Mention related files
|
|
338
|
+
if history["related_files"]:
|
|
339
|
+
files_sample = list(history["related_files"])[:2]
|
|
340
|
+
parts.append(f"Related files: {', '.join(files_sample)}")
|
|
341
|
+
|
|
342
|
+
return "; ".join(parts)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def analyze_item(item, domain, repo_path, commits):
|
|
346
|
+
"""Analyze a single tracker item.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
dict: {slug, risk_level, estimated_retries, justification}
|
|
350
|
+
"""
|
|
351
|
+
if not isinstance(item, dict):
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
slug = item.get("slug")
|
|
355
|
+
if not slug:
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
# Analyze domain history
|
|
359
|
+
if domain:
|
|
360
|
+
history = analyze_domain_history(repo_path, domain, commits)
|
|
361
|
+
else:
|
|
362
|
+
history = {
|
|
363
|
+
"feature_commits": 0,
|
|
364
|
+
"fixforward_commits": 0,
|
|
365
|
+
"repair_frequency": 0.0,
|
|
366
|
+
"related_files": set(),
|
|
367
|
+
"total_commits": 0,
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Compute risk and retries
|
|
371
|
+
risk_level = compute_risk_level(domain or "unknown", history, item)
|
|
372
|
+
estimated_retries = compute_estimated_retries(history)
|
|
373
|
+
|
|
374
|
+
# Build justification
|
|
375
|
+
justification = build_justification(domain or "unknown", item, history)
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
"slug": slug,
|
|
379
|
+
"risk_level": risk_level,
|
|
380
|
+
"estimated_retries": estimated_retries,
|
|
381
|
+
"justification": justification,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def run_analysis(root_dir=None, state_dir=None, config=None):
|
|
386
|
+
"""Run full backlog analysis.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
dict: {items: [analysis results]}
|
|
390
|
+
"""
|
|
391
|
+
if root_dir is None:
|
|
392
|
+
root_dir = Path.cwd()
|
|
393
|
+
else:
|
|
394
|
+
root_dir = Path(root_dir)
|
|
395
|
+
|
|
396
|
+
if config is None:
|
|
397
|
+
config = load_config(root_dir)
|
|
398
|
+
|
|
399
|
+
if state_dir is None:
|
|
400
|
+
state_dir = resolve_state_dir(root_dir, config)
|
|
401
|
+
else:
|
|
402
|
+
state_dir = Path(state_dir)
|
|
403
|
+
|
|
404
|
+
# Load tracker items
|
|
405
|
+
items = load_tracker_json(state_dir)
|
|
406
|
+
|
|
407
|
+
# Get recent commits
|
|
408
|
+
commits = get_commits_since(root_dir, days=30)
|
|
409
|
+
|
|
410
|
+
# Analyze each item
|
|
411
|
+
results = []
|
|
412
|
+
for item in items:
|
|
413
|
+
# Extract domain hint
|
|
414
|
+
domain = extract_domain_from_item(item)
|
|
415
|
+
|
|
416
|
+
# Analyze item
|
|
417
|
+
analysis = analyze_item(item, domain, root_dir, commits)
|
|
418
|
+
if analysis:
|
|
419
|
+
results.append(analysis)
|
|
420
|
+
|
|
421
|
+
return {"items": results}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def main(argv=None):
|
|
425
|
+
"""CLI entry point."""
|
|
426
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
427
|
+
|
|
428
|
+
root_dir = None
|
|
429
|
+
state_dir = None
|
|
430
|
+
output_format = "text"
|
|
431
|
+
|
|
432
|
+
# Parse arguments
|
|
433
|
+
i = 0
|
|
434
|
+
while i < len(argv):
|
|
435
|
+
arg = argv[i]
|
|
436
|
+
if arg == "--root":
|
|
437
|
+
i += 1
|
|
438
|
+
if i < len(argv):
|
|
439
|
+
root_dir = argv[i]
|
|
440
|
+
i += 1
|
|
441
|
+
elif arg.startswith("--root="):
|
|
442
|
+
root_dir = arg[len("--root="):]
|
|
443
|
+
i += 1
|
|
444
|
+
elif arg == "--state-root":
|
|
445
|
+
i += 1
|
|
446
|
+
if i < len(argv):
|
|
447
|
+
state_dir = argv[i]
|
|
448
|
+
i += 1
|
|
449
|
+
elif arg.startswith("--state-root="):
|
|
450
|
+
state_dir = arg[len("--state-root="):]
|
|
451
|
+
i += 1
|
|
452
|
+
elif arg == "--json":
|
|
453
|
+
output_format = "json"
|
|
454
|
+
i += 1
|
|
455
|
+
else:
|
|
456
|
+
print(f"Unknown argument: {arg}", file=sys.stderr)
|
|
457
|
+
return 0 # Warn-level: don't fail
|
|
458
|
+
|
|
459
|
+
if root_dir is None:
|
|
460
|
+
root_dir = Path.cwd()
|
|
461
|
+
else:
|
|
462
|
+
root_dir = Path(root_dir)
|
|
463
|
+
|
|
464
|
+
config = load_config(root_dir)
|
|
465
|
+
if state_dir is None:
|
|
466
|
+
state_dir = resolve_state_dir(root_dir, config)
|
|
467
|
+
else:
|
|
468
|
+
state_dir = Path(state_dir)
|
|
469
|
+
|
|
470
|
+
result = run_analysis(root_dir, state_dir, config)
|
|
471
|
+
|
|
472
|
+
if output_format == "json":
|
|
473
|
+
print(json.dumps(result, indent=2))
|
|
474
|
+
else:
|
|
475
|
+
# Text format
|
|
476
|
+
print("Backlog Risk Analysis:")
|
|
477
|
+
if not result["items"]:
|
|
478
|
+
print(" (no items to analyze)")
|
|
479
|
+
else:
|
|
480
|
+
for item in result["items"]:
|
|
481
|
+
print(f"\n {item['slug']}: {item['risk_level'].upper()}")
|
|
482
|
+
if item["estimated_retries"] is not None:
|
|
483
|
+
print(f" Estimated retries: {item['estimated_retries']}")
|
|
484
|
+
print(f" {item['justification']}")
|
|
485
|
+
|
|
486
|
+
return 0 # Always exit 0 (warn-level)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
if __name__ == "__main__":
|
|
490
|
+
sys.exit(main())
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
r"""
|
|
3
|
+
Wave Ledger Hook — orchestrator wrapper to append per-wave telemetry to OUTCOMES-LEDGER.md.
|
|
4
|
+
|
|
5
|
+
Orchestrator-tail command to write build/verify/repair phase outcomes from a workflow report
|
|
6
|
+
into the aesop ledger, closing the 'ledger empty, all telemetry reconstructed from transcripts'
|
|
7
|
+
gap. Calls fleet_ledger.append_wave for each phase to ensure idempotency and consistency.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python tools/wave_ledger_hook.py --report-file <path> --wave <id> --timestamp <iso>
|
|
11
|
+
|
|
12
|
+
Arguments:
|
|
13
|
+
--report-file Path to workflow report JSON (required)
|
|
14
|
+
Shape: {tokens:{buildOut,verifyOut,repairOut,totalOut},
|
|
15
|
+
integration:{green,passed,failed}, repairsUsed,
|
|
16
|
+
build:[{slug,...}], adversarialReviewMode?}
|
|
17
|
+
--wave Wave ID (required, integer)
|
|
18
|
+
--timestamp ISO 8601 timestamp (required, never invented; string without pipes/newlines)
|
|
19
|
+
|
|
20
|
+
Behavior:
|
|
21
|
+
- Validates timestamp: rejects if contains pipe (|) or newline characters
|
|
22
|
+
- Calls fleet_ledger.append_wave for each phase present in report
|
|
23
|
+
- Appends rows to OUTCOMES-LEDGER.md in state/ledger/ directory
|
|
24
|
+
- Idempotent: safe to call multiple times with same parameters (existing rows skipped)
|
|
25
|
+
- Returns exit 0 on success, 1 on validation/parse errors
|
|
26
|
+
|
|
27
|
+
Exit codes:
|
|
28
|
+
0 = All phases successfully appended (or already exist)
|
|
29
|
+
1 = Validation error, missing argument, parse error, or append failure
|
|
30
|
+
|
|
31
|
+
Environment:
|
|
32
|
+
AESOP_STATE_ROOT: Path to state directory (default: ./state relative to cwd)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import sys
|
|
36
|
+
import json
|
|
37
|
+
import argparse
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from fleet_ledger import append_wave
|
|
42
|
+
except ImportError:
|
|
43
|
+
from tools.fleet_ledger import append_wave
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_timestamp(timestamp_str):
|
|
47
|
+
"""Validate timestamp string for safety.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
timestamp_str: ISO 8601 timestamp string to validate
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
(is_valid, error_message) tuple
|
|
54
|
+
- is_valid: True if timestamp is safe, False if contains pipes/newlines
|
|
55
|
+
- error_message: Human-readable error if invalid, empty string if valid
|
|
56
|
+
"""
|
|
57
|
+
if not timestamp_str:
|
|
58
|
+
return False, "Timestamp cannot be empty"
|
|
59
|
+
|
|
60
|
+
# Reject pipe (markdown table injection)
|
|
61
|
+
if '|' in timestamp_str:
|
|
62
|
+
return False, "Timestamp cannot contain pipe character (|)"
|
|
63
|
+
|
|
64
|
+
# Reject newline and carriage return (table formatting break)
|
|
65
|
+
if '\n' in timestamp_str or '\r' in timestamp_str:
|
|
66
|
+
return False, "Timestamp cannot contain newline characters"
|
|
67
|
+
|
|
68
|
+
return True, ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main():
|
|
72
|
+
parser = argparse.ArgumentParser(
|
|
73
|
+
description='Append per-wave telemetry to OUTCOMES-LEDGER.md'
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
'--report-file',
|
|
77
|
+
required=True,
|
|
78
|
+
help='Path to workflow report JSON'
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
'--wave',
|
|
82
|
+
required=True,
|
|
83
|
+
help='Wave ID (integer)'
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
'--timestamp',
|
|
87
|
+
required=True,
|
|
88
|
+
help='ISO 8601 timestamp (required, never invented)'
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
args = parser.parse_args()
|
|
92
|
+
|
|
93
|
+
# Validate timestamp
|
|
94
|
+
is_valid, error_msg = validate_timestamp(args.timestamp)
|
|
95
|
+
if not is_valid:
|
|
96
|
+
print(f"ERROR: Invalid timestamp: {error_msg}")
|
|
97
|
+
return 1
|
|
98
|
+
|
|
99
|
+
# Read and parse report
|
|
100
|
+
try:
|
|
101
|
+
report_path = Path(args.report_file)
|
|
102
|
+
if not report_path.exists():
|
|
103
|
+
print(f"ERROR: Report file not found: {args.report_file}")
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
report_text = report_path.read_text(encoding='utf-8')
|
|
107
|
+
report = json.loads(report_text)
|
|
108
|
+
except json.JSONDecodeError as e:
|
|
109
|
+
print(f"ERROR: Failed to parse report JSON: {e}")
|
|
110
|
+
return 1
|
|
111
|
+
except (IOError, OSError) as e:
|
|
112
|
+
print(f"ERROR: Failed to read report file: {e}")
|
|
113
|
+
return 1
|
|
114
|
+
|
|
115
|
+
# Determine which phases to append based on report contents
|
|
116
|
+
phases_to_append = []
|
|
117
|
+
|
|
118
|
+
# Always append build phase if we have token data
|
|
119
|
+
tokens_section = report.get('tokens', {})
|
|
120
|
+
if 'buildOut' in tokens_section or 'build' in report:
|
|
121
|
+
phases_to_append.append('build')
|
|
122
|
+
|
|
123
|
+
# Append verify phase if we have token data
|
|
124
|
+
if 'verifyOut' in tokens_section:
|
|
125
|
+
phases_to_append.append('verify')
|
|
126
|
+
|
|
127
|
+
# Append repair phase only if repairsUsed > 0
|
|
128
|
+
repairs_used = report.get('repairsUsed', 0)
|
|
129
|
+
if repairs_used > 0 and 'repairOut' in tokens_section:
|
|
130
|
+
phases_to_append.append('repair')
|
|
131
|
+
|
|
132
|
+
# If no phases identified, default to all three if any token data exists
|
|
133
|
+
if not phases_to_append and tokens_section:
|
|
134
|
+
phases_to_append = ['build', 'verify', 'repair']
|
|
135
|
+
|
|
136
|
+
# Append each phase
|
|
137
|
+
all_succeeded = True
|
|
138
|
+
for phase in phases_to_append:
|
|
139
|
+
success, message = append_wave(args.report_file, args.wave, phase, args.timestamp)
|
|
140
|
+
if not success:
|
|
141
|
+
print(f"ERROR: Failed to append {phase} phase: {message}")
|
|
142
|
+
all_succeeded = False
|
|
143
|
+
else:
|
|
144
|
+
print(f"OK: {message}")
|
|
145
|
+
|
|
146
|
+
return 0 if all_succeeded else 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == '__main__':
|
|
150
|
+
sys.exit(main())
|