@matt82198/aesop 0.1.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -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/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. 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())