@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,779 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Wave preflight validator — check repo readiness before starting a wave.
4
+
5
+ Validates:
6
+ 1. Current repo is on a feature branch (never main/master)
7
+ 2. Working tree clean
8
+ 3. STATE.md phase heading consistent with state/orchestrator-status.json phase
9
+ (warning-level: drift is reported but does not block the wave)
10
+ 4. No .HALT sentinel
11
+ 5. Heartbeats fresh (watchdog 200s) and orchestrator-status.json fresh (300s)
12
+ 6. state/tracker.json parses as JSON
13
+ 7. secret_scan importable
14
+
15
+ Exit codes:
16
+ 0 = ready (all checks pass, or only warnings like phase drift)
17
+ 1 = blocked (one or more critical checks failed)
18
+
19
+ Output:
20
+ --text (default): numbered list of checks with status and detail
21
+ --json: {ready: bool, checks: [{name, ok, detail}]}
22
+
23
+ Usage:
24
+ python tools/wave_preflight.py [--root REPO_ROOT] [--state-root STATE_ROOT] [--json]
25
+
26
+ Arguments:
27
+ --root REPO_ROOT: repository root directory (default: cwd)
28
+ --state-root STATE_ROOT: state directory (default: REPO_ROOT/state or ./state)
29
+ --json: output in JSON format (default: text)
30
+
31
+ Environment:
32
+ AESOP_STATE_ROOT: state dir (takes precedence over --state-root argument)
33
+ AESOP_ROOT: repo root (default: cwd or inferred from .git)
34
+ """
35
+
36
+ import json
37
+ import os
38
+ import re
39
+ import subprocess
40
+ import sys
41
+ from pathlib import Path
42
+
43
+ # Ensure both tools and state_store are importable (sys.path fix for bootstrapping)
44
+ repo_root = Path(__file__).parent.parent
45
+ if str(repo_root) not in sys.path:
46
+ sys.path.insert(0, str(repo_root))
47
+
48
+ try:
49
+ from common import get_state_dir, check_heartbeat_staleness
50
+ except ImportError:
51
+ from tools.common import get_state_dir, check_heartbeat_staleness
52
+
53
+ try:
54
+ import halt
55
+ except ImportError:
56
+ from tools import halt
57
+
58
+ # Import ReadAPI unconditionally - import failure is a loud error
59
+ from state_store.read_api import ReadAPI
60
+
61
+
62
+ def load_config(root_dir=None):
63
+ """Load aesop.config.json from root, return dict (or {} if absent/bad)."""
64
+ if root_dir is None:
65
+ root_dir = Path.cwd()
66
+ else:
67
+ root_dir = Path(root_dir)
68
+
69
+ config_file = root_dir / "aesop.config.json"
70
+ if not config_file.exists():
71
+ return {}
72
+ try:
73
+ with open(config_file, "r", encoding="utf-8") as f:
74
+ return json.load(f)
75
+ except Exception:
76
+ return {}
77
+
78
+
79
+ def resolve_state_dir(root_dir=None, config=None):
80
+ """Resolve state dir: AESOP_STATE_ROOT env > config state_root > ./state."""
81
+ if os.environ.get("AESOP_STATE_ROOT"):
82
+ return Path(os.environ["AESOP_STATE_ROOT"])
83
+
84
+ if root_dir is None:
85
+ root_dir = Path.cwd()
86
+ else:
87
+ root_dir = Path(root_dir)
88
+
89
+ if config is None:
90
+ config = load_config(root_dir)
91
+
92
+ state_root = config.get("state_root") if isinstance(config, dict) else None
93
+ if state_root:
94
+ p = Path(state_root).expanduser()
95
+ if not p.is_absolute():
96
+ p = root_dir / p
97
+ return p
98
+
99
+ return root_dir / "state"
100
+
101
+
102
+ def parse_state_md_phase(state_md_path):
103
+ """Extract phase from STATE.md heading: ## Phase: `<phase>` ...
104
+
105
+ Returns:
106
+ str or None: the phase name, or None if not found/unparseable.
107
+ """
108
+ if not state_md_path.exists():
109
+ return None
110
+
111
+ try:
112
+ content = state_md_path.read_text(encoding="utf-8")
113
+ # Match: ## Phase: `<phase>` ...
114
+ match = re.search(r'^##\s+Phase:\s+`([^`]+)`', content, re.MULTILINE)
115
+ if match:
116
+ return match.group(1)
117
+ except Exception:
118
+ pass
119
+
120
+ return None
121
+
122
+
123
+ def parse_orchestrator_status_phase(status_json_path):
124
+ """Extract phase from orchestrator-status.json.
125
+
126
+ Returns:
127
+ str or None: the phase field, or None if not found/unparseable.
128
+ """
129
+ if not status_json_path.exists():
130
+ return None
131
+
132
+ try:
133
+ data = json.loads(status_json_path.read_text(encoding="utf-8"))
134
+ return data.get("phase")
135
+ except Exception:
136
+ pass
137
+
138
+ return None
139
+
140
+
141
+ def check_orchestrator_status_freshness(status_json_path, threshold_s):
142
+ """Check if orchestrator-status.json is fresh based on updated_at timestamp.
143
+
144
+ Uses ReadAPI (state_store.read_api) for all checks - single source of truth.
145
+
146
+ Args:
147
+ status_json_path: Path to orchestrator-status.json (or state dir)
148
+ threshold_s: Staleness threshold in seconds
149
+
150
+ Returns:
151
+ Tuple of (is_stale, age_s, info):
152
+ is_stale (bool): True if file missing, unreadable, or age >= threshold_s
153
+ age_s (int): Age in seconds (0 if file missing/unreadable)
154
+ info (str or None): Descriptive message if stale/missing, None if fresh
155
+ """
156
+ try:
157
+ # Determine state_dir: if status_json_path is the status file, use its parent
158
+ status_path = Path(status_json_path)
159
+ if status_path.name == "orchestrator-status.json":
160
+ state_dir = status_path.parent
161
+ else:
162
+ state_dir = status_path
163
+
164
+ api = ReadAPI(str(state_dir))
165
+ status = api.read_orchestrator_status()
166
+ if status is None:
167
+ return True, 0, "orchestrator-status.json file missing or unreadable"
168
+
169
+ # Check freshness
170
+ updated_at = status.get("updated_at")
171
+ if not updated_at:
172
+ return True, 0, "orchestrator-status.json missing updated_at field"
173
+
174
+ # Parse ISO 8601 timestamp
175
+ from datetime import datetime, timezone
176
+ normalized_ts = updated_at.replace("Z", "+00:00")
177
+ updated_dt = datetime.fromisoformat(normalized_ts)
178
+ timestamp = updated_dt.timestamp()
179
+
180
+ import time
181
+ age_seconds = int(time.time()) - int(timestamp)
182
+
183
+ # Check for far-future timestamp (clock skew beyond tolerance)
184
+ if age_seconds < -120:
185
+ return True, 0, "orchestrator-status timestamp in future (clock skew)"
186
+
187
+ # Clamp small negative ages to 0
188
+ age_seconds = max(0, age_seconds)
189
+
190
+ if age_seconds >= threshold_s:
191
+ return True, age_seconds, f"orchestrator-status stale ({age_seconds}s >= {threshold_s}s)"
192
+
193
+ return False, age_seconds, None
194
+ except Exception as e:
195
+ return True, 0, f"orchestrator-status.json unreadable: {e}"
196
+
197
+
198
+ def is_git_repo(root_dir):
199
+ """Check if root_dir is a git repository."""
200
+ git_dir = Path(root_dir) / ".git"
201
+ return git_dir.exists()
202
+
203
+
204
+ def get_current_branch(root_dir):
205
+ """Get current git branch name.
206
+
207
+ Returns:
208
+ str or None: branch name, or None if unable to determine (e.g., not a repo, detached HEAD).
209
+ """
210
+ try:
211
+ result = subprocess.run(
212
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
213
+ cwd=root_dir,
214
+ capture_output=True,
215
+ text=True,
216
+ timeout=5,
217
+ )
218
+ if result.returncode == 0:
219
+ branch = result.stdout.strip()
220
+ if branch == "HEAD":
221
+ # Detached HEAD
222
+ return None
223
+ return branch
224
+ except Exception:
225
+ pass
226
+ return None
227
+
228
+
229
+ def is_working_tree_clean(root_dir):
230
+ """Check if git working tree is clean (ignores untracked files).
231
+
232
+ Returns:
233
+ (bool, str or None): (is_clean, detail_msg if not clean)
234
+ """
235
+ try:
236
+ result = subprocess.run(
237
+ ["git", "status", "--porcelain"],
238
+ cwd=root_dir,
239
+ capture_output=True,
240
+ text=True,
241
+ timeout=5,
242
+ )
243
+ if result.returncode == 0:
244
+ output = result.stdout.strip()
245
+ if not output:
246
+ return True, None
247
+ # Filter out untracked files (lines starting with ??)
248
+ # and only consider tracked file changes (M, A, D, etc.)
249
+ dirty_lines = [
250
+ line for line in output.split("\n")
251
+ if line and not line.startswith("??")
252
+ ]
253
+ if not dirty_lines:
254
+ return True, None
255
+ # List first few dirty files
256
+ lines = dirty_lines[:3]
257
+ detail = "uncommitted changes: " + "; ".join(lines)
258
+ if len(dirty_lines) > 3:
259
+ detail += f" (+{len(dirty_lines) - 3} more)"
260
+ return False, detail
261
+ except Exception:
262
+ pass
263
+ return False, "unable to check git status"
264
+
265
+
266
+ def can_import_secret_scan():
267
+ """Check if secret_scan can be imported.
268
+
269
+ Returns:
270
+ (bool, str or None): (importable, detail_msg if not)
271
+ """
272
+ try:
273
+ import secret_scan
274
+ return True, None
275
+ except ImportError as e:
276
+ return False, str(e)
277
+
278
+
279
+ def scan_backlog_items(tracker_path, work_dir, ledger_path=None):
280
+ """Scan backlog items for risky conditions.
281
+
282
+ Analyzes todo items in tracker.json for:
283
+ (a) missing/empty owns_files (file ownership info)
284
+ (b) stale items (references to non-existent files)
285
+ (c) overlaps (two items owning same file)
286
+ (d) history signal (retry rate from ledger)
287
+
288
+ Args:
289
+ tracker_path: Path to state/tracker.json
290
+ work_dir: Working directory for resolving relative file paths
291
+ ledger_path: Optional path to ledger for history stats
292
+
293
+ Returns:
294
+ dict with structure:
295
+ {
296
+ "success": bool,
297
+ "items": [
298
+ {
299
+ "id": str,
300
+ "title": str,
301
+ "flags": [{"type": str, "detail": str}, ...]
302
+ },
303
+ ...
304
+ ],
305
+ "ledger_stats": dict or None,
306
+ "summary": str
307
+ }
308
+ """
309
+ tracker_path = Path(tracker_path)
310
+ work_dir = Path(work_dir)
311
+ result = {
312
+ "success": True,
313
+ "items": [],
314
+ "ledger_stats": None,
315
+ "summary": ""
316
+ }
317
+
318
+ # Load tracker.json
319
+ try:
320
+ if not tracker_path.exists():
321
+ result["success"] = False
322
+ result["summary"] = f"tracker file not found: {tracker_path}"
323
+ return result
324
+
325
+ tracker_data = json.loads(tracker_path.read_text(encoding="utf-8"))
326
+ if not isinstance(tracker_data, dict) or "items" not in tracker_data:
327
+ result["success"] = False
328
+ result["summary"] = "tracker.json invalid: missing 'items' key"
329
+ return result
330
+
331
+ items = tracker_data.get("items", [])
332
+ except (json.JSONDecodeError, IOError) as e:
333
+ result["success"] = False
334
+ result["summary"] = f"tracker.json unreadable: {e}"
335
+ return result
336
+
337
+ # Filter to todo items only
338
+ todo_items = [
339
+ item for item in items
340
+ if isinstance(item, dict) and item.get("status") == "todo"
341
+ ]
342
+
343
+ # Build ownership map for overlap detection
344
+ ownership_map = {} # file_path -> set of item_ids
345
+
346
+ # Process each todo item
347
+ for item in todo_items:
348
+ item_id = item.get("id", "unknown")
349
+ title = item.get("title", "")
350
+ flags = []
351
+
352
+ # Flag (a): Check for missing/empty owns_files
353
+ owns_files = item.get("owns_files", [])
354
+ if not owns_files or (isinstance(owns_files, list) and len(owns_files) == 0):
355
+ flags.append({
356
+ "type": "missing_ownership",
357
+ "detail": "Item has no owns_files; cannot dispatch safely"
358
+ })
359
+
360
+ # Flag (b): Check for stale items (files not found)
361
+ if owns_files and isinstance(owns_files, list):
362
+ for file_ref in owns_files:
363
+ file_path = work_dir / file_ref if not Path(file_ref).is_absolute() else Path(file_ref)
364
+ if not file_path.exists():
365
+ flags.append({
366
+ "type": "stale_reference",
367
+ "detail": f"Referenced file not found: {file_ref}"
368
+ })
369
+
370
+ # Track for overlap detection
371
+ file_key = str(file_path.resolve())
372
+ if file_key not in ownership_map:
373
+ ownership_map[file_key] = set()
374
+ ownership_map[file_key].add(item_id)
375
+
376
+ if flags or owns_files: # Only include items with flags or ownership info
377
+ result["items"].append({
378
+ "id": item_id,
379
+ "title": title,
380
+ "flags": flags
381
+ })
382
+
383
+ # Flag (c): Detect overlaps
384
+ for file_path, item_ids in ownership_map.items():
385
+ if len(item_ids) > 1:
386
+ for item_id in item_ids:
387
+ # Find the item and add overlap flag
388
+ for item_result in result["items"]:
389
+ if item_result["id"] == item_id:
390
+ overlapping_ids = sorted(item_ids - {item_id})
391
+ item_result["flags"].append({
392
+ "type": "ownership_overlap",
393
+ "detail": f"File shared with items: {', '.join(overlapping_ids)}"
394
+ })
395
+ break
396
+
397
+ # Flag (d): Load ledger stats if provided
398
+ if ledger_path:
399
+ ledger_path = Path(ledger_path)
400
+ if ledger_path.exists():
401
+ try:
402
+ ledger_stats = _analyze_ledger(ledger_path)
403
+ result["ledger_stats"] = ledger_stats
404
+ except Exception as e:
405
+ result["ledger_stats"] = {"error": str(e), "data": "DATA-UNAVAILABLE"}
406
+ else:
407
+ result["ledger_stats"] = {"data": "DATA-UNAVAILABLE", "note": "ledger not found"}
408
+ else:
409
+ result["ledger_stats"] = {"data": "DATA-UNAVAILABLE", "note": "no ledger provided"}
410
+
411
+ # Summary
412
+ flagged_count = sum(1 for item in result["items"] if item["flags"])
413
+ result["summary"] = f"Scanned {len(todo_items)} todo items; {flagged_count} have risk flags"
414
+
415
+ return result
416
+
417
+
418
+ def _analyze_ledger(ledger_path):
419
+ """Extract repair/retry statistics from ledger.
420
+
421
+ Returns:
422
+ dict with overall repair rate and stats
423
+ """
424
+ ledger_path = Path(ledger_path)
425
+ try:
426
+ content = ledger_path.read_text(encoding="utf-8")
427
+ except IOError:
428
+ return {"data": "DATA-UNAVAILABLE", "error": "unreadable"}
429
+
430
+ lines = content.split("\n")
431
+ entries = []
432
+
433
+ for line in lines:
434
+ # Skip header and separator lines
435
+ if not line.strip() or "---|" in line or not line.startswith("|"):
436
+ continue
437
+
438
+ # Parse markdown table row
439
+ cells = [c.strip() for c in line.split("|")[1:-1]]
440
+ if len(cells) < 7:
441
+ continue
442
+
443
+ # Skip header row (first cell is "ISO ts" or similar header name)
444
+ if cells[0] in ("ISO ts", "iso_ts") or cells[0].startswith("ISO"):
445
+ continue
446
+
447
+ try:
448
+ # Columns: ISO ts, agent_type, model, duration_sec, tokens_in, tokens_out, verdict, phase, wave
449
+ verdict = cells[6] if len(cells) > 6 else "OK"
450
+ phase = cells[7].strip() if len(cells) > 7 else None
451
+ entries.append({"verdict": verdict, "phase": phase})
452
+ except (ValueError, IndexError):
453
+ continue
454
+
455
+ if not entries:
456
+ return {"data": "DATA-UNAVAILABLE", "reason": "no ledger entries"}
457
+
458
+ # Calculate retry rate: entries with FAILED verdict / total
459
+ failed_count = sum(1 for e in entries if e.get("verdict") == "FAILED")
460
+ total_count = len(entries)
461
+ repair_count = sum(1 for e in entries if e.get("phase") == "repair")
462
+
463
+ retry_rate = failed_count / total_count if total_count > 0 else 0
464
+
465
+ return {
466
+ "total_entries": total_count,
467
+ "failed_count": failed_count,
468
+ "repair_count": repair_count,
469
+ "retry_rate": round(retry_rate, 3),
470
+ "data": "AVAILABLE"
471
+ }
472
+
473
+
474
+ def run_checks(root_dir=None, state_dir=None, config=None):
475
+ """Run all preflight checks.
476
+
477
+ Args:
478
+ root_dir: repo root (inferred from cwd if None)
479
+ state_dir: state dir (resolved if None)
480
+ config: aesop.config.json dict (loaded if None)
481
+
482
+ Returns:
483
+ dict: {
484
+ ready: bool (all checks pass/warn),
485
+ checks: [
486
+ {name: str, ok: bool, detail: str},
487
+ ...
488
+ ]
489
+ }
490
+ """
491
+ if root_dir is None:
492
+ root_dir = Path.cwd()
493
+ else:
494
+ root_dir = Path(root_dir)
495
+
496
+ if config is None:
497
+ config = load_config(root_dir)
498
+
499
+ if state_dir is None:
500
+ state_dir = resolve_state_dir(root_dir, config)
501
+ else:
502
+ state_dir = Path(state_dir)
503
+
504
+ checks = []
505
+
506
+ # Check 1: Git repo exists
507
+ is_repo = is_git_repo(root_dir)
508
+ checks.append({
509
+ "name": "Git repository",
510
+ "ok": is_repo,
511
+ "detail": "repo found" if is_repo else "not a git repo",
512
+ })
513
+
514
+ if not is_repo:
515
+ # Can't proceed without a repo
516
+ return {"ready": False, "checks": checks}
517
+
518
+ # Check 2: On a feature branch (not main/master)
519
+ branch = get_current_branch(root_dir)
520
+ if branch is None:
521
+ on_feature_branch = False
522
+ detail = "detached HEAD or unable to determine branch"
523
+ else:
524
+ on_feature_branch = branch not in ("main", "master")
525
+ detail = f"branch={branch}"
526
+
527
+ checks.append({
528
+ "name": "Feature branch (not main/master)",
529
+ "ok": on_feature_branch,
530
+ "detail": detail,
531
+ })
532
+
533
+ # Check 3: Working tree clean
534
+ clean, dirty_detail = is_working_tree_clean(root_dir)
535
+ checks.append({
536
+ "name": "Working tree clean",
537
+ "ok": clean,
538
+ "detail": dirty_detail or "no uncommitted changes",
539
+ })
540
+
541
+ # Check 4: No .HALT sentinel
542
+ is_halted = halt.is_halted(state_dir)
543
+ halt_detail = "not halted"
544
+ if is_halted:
545
+ halt_info = halt.get_halt_info(state_dir)
546
+ halt_detail = halt_info.get("reason", "halted") if halt_info else "halted"
547
+
548
+ checks.append({
549
+ "name": "No .HALT sentinel",
550
+ "ok": not is_halted,
551
+ "detail": halt_detail,
552
+ })
553
+
554
+ # Check 5: STATE.md phase vs orchestrator-status.json phase (warn-level)
555
+ # Genuine comparison: only warn if both phases exist and differ
556
+ state_md_path = root_dir / "STATE.md"
557
+ status_json_path = state_dir / "orchestrator-status.json"
558
+
559
+ state_phase = parse_state_md_phase(state_md_path)
560
+ status_phase = parse_orchestrator_status_phase(status_json_path)
561
+
562
+ # Determine phase drift: only if both are defined and differ
563
+ drift_detected = False
564
+ if state_phase is not None and status_phase is not None:
565
+ if state_phase != status_phase:
566
+ # Drift detected: both phases exist but differ
567
+ drift_detected = True
568
+ phase_detail = f"STATE.md={state_phase}, status.json={status_phase} [WARN: drift detected]"
569
+ else:
570
+ # Phases match
571
+ phase_detail = f"STATE.md={state_phase}, status.json={status_phase}"
572
+ else:
573
+ # One or both phases missing (not yet ready or not applicable)
574
+ phase_detail = f"STATE.md={state_phase}, status.json={status_phase}"
575
+
576
+ # Phase drift check: warning-level (drift is reported but does not block)
577
+ # The check always passes (phase_ok = True), but drift detail is visible in output
578
+ # This makes the check non-vacuous: drift is detected and reported, but doesn't block
579
+ phase_ok = True
580
+
581
+ checks.append({
582
+ "name": "STATE.md phase consistent with orchestrator-status.json (warning-level)",
583
+ "ok": phase_ok,
584
+ "detail": phase_detail,
585
+ })
586
+
587
+ # Check 6: Heartbeats and status freshness
588
+ # Check watchdog heartbeat (200s threshold) and orchestrator-status.json (300s)
589
+ heartbeat_details = []
590
+ all_heartbeats_ok = True
591
+
592
+ watchdog_hb = state_dir / ".watchdog-heartbeat"
593
+ is_stale, age, info = check_heartbeat_staleness(watchdog_hb, 200)
594
+ hb_name = "watchdog"
595
+ if is_stale:
596
+ all_heartbeats_ok = False
597
+ heartbeat_details.append(f"{hb_name}: {info} (age={age}s)")
598
+ else:
599
+ heartbeat_details.append(f"{hb_name}: fresh (age={age}s)")
600
+
601
+ # Check orchestrator-status.json updated_at field (300s threshold)
602
+ status_json_path = state_dir / "orchestrator-status.json"
603
+ is_stale, age, info = check_orchestrator_status_freshness(status_json_path, 300)
604
+ status_name = "orchestrator-status"
605
+ if is_stale:
606
+ all_heartbeats_ok = False
607
+ heartbeat_details.append(f"{status_name}: {info} (age={age}s)")
608
+ else:
609
+ heartbeat_details.append(f"{status_name}: fresh (age={age}s)")
610
+
611
+ checks.append({
612
+ "name": "Heartbeats and orchestrator status fresh",
613
+ "ok": all_heartbeats_ok,
614
+ "detail": "; ".join(heartbeat_details),
615
+ })
616
+
617
+ # Check 7: state/tracker.json parses as JSON
618
+ tracker_json_path = state_dir / "tracker.json"
619
+ tracker_ok = False
620
+ tracker_detail = "tracker.json not found"
621
+
622
+ if tracker_json_path.exists():
623
+ try:
624
+ json.loads(tracker_json_path.read_text(encoding="utf-8"))
625
+ tracker_ok = True
626
+ tracker_detail = "valid JSON"
627
+ except json.JSONDecodeError as e:
628
+ tracker_detail = f"invalid JSON: {e}"
629
+ except Exception as e:
630
+ tracker_detail = f"unreadable: {e}"
631
+
632
+ checks.append({
633
+ "name": "state/tracker.json parses as JSON",
634
+ "ok": tracker_ok,
635
+ "detail": tracker_detail,
636
+ })
637
+
638
+ # Check 8: secret_scan importable
639
+ can_import, import_detail = can_import_secret_scan()
640
+ checks.append({
641
+ "name": "secret_scan importable",
642
+ "ok": can_import,
643
+ "detail": import_detail or "importable",
644
+ })
645
+
646
+ # Determine overall readiness: pass if all checks ok (warnings don't fail)
647
+ # For now, all checks must pass (warnings are just flags in detail)
648
+ all_ok = all(c["ok"] for c in checks)
649
+
650
+ return {
651
+ "ready": all_ok,
652
+ "checks": checks,
653
+ }
654
+
655
+
656
+ def main(argv=None):
657
+ """CLI entry point."""
658
+ argv = sys.argv[1:] if argv is None else argv
659
+
660
+ root_dir = None
661
+ state_dir = None
662
+ tracker_path = None
663
+ ledger_path = None
664
+ output_format = "text"
665
+
666
+ # Parse arguments
667
+ i = 0
668
+ while i < len(argv):
669
+ arg = argv[i]
670
+ if arg == "--root":
671
+ i += 1
672
+ if i < len(argv):
673
+ root_dir = argv[i]
674
+ i += 1
675
+ elif arg.startswith("--root="):
676
+ root_dir = arg[len("--root="):]
677
+ i += 1
678
+ elif arg == "--state-root":
679
+ i += 1
680
+ if i < len(argv):
681
+ state_dir = argv[i]
682
+ i += 1
683
+ elif arg.startswith("--state-root="):
684
+ state_dir = arg[len("--state-root="):]
685
+ i += 1
686
+ elif arg == "--tracker":
687
+ i += 1
688
+ if i < len(argv):
689
+ tracker_path = argv[i]
690
+ i += 1
691
+ elif arg.startswith("--tracker="):
692
+ tracker_path = arg[len("--tracker="):]
693
+ i += 1
694
+ elif arg == "--ledger":
695
+ i += 1
696
+ if i < len(argv):
697
+ ledger_path = argv[i]
698
+ i += 1
699
+ elif arg.startswith("--ledger="):
700
+ ledger_path = arg[len("--ledger="):]
701
+ i += 1
702
+ elif arg == "--json":
703
+ output_format = "json"
704
+ i += 1
705
+ else:
706
+ print(f"Unknown argument: {arg}", file=sys.stderr)
707
+ return 2
708
+
709
+ if root_dir is None:
710
+ root_dir = Path.cwd()
711
+ else:
712
+ root_dir = Path(root_dir)
713
+
714
+ config = load_config(root_dir)
715
+ if state_dir is None:
716
+ state_dir = resolve_state_dir(root_dir, config)
717
+ else:
718
+ state_dir = Path(state_dir)
719
+
720
+ # If --tracker provided, run backlog analysis instead of repo readiness checks
721
+ if tracker_path:
722
+ work_dir = root_dir # Use root_dir as the working directory for file resolution
723
+ result = scan_backlog_items(tracker_path, work_dir, ledger_path)
724
+
725
+ if output_format == "json":
726
+ print(json.dumps(result, indent=2))
727
+ else:
728
+ # Text format: summary + items with flags
729
+ print(f"Backlog Preflight: {result['summary']}")
730
+ print()
731
+
732
+ if result["ledger_stats"]:
733
+ stats = result["ledger_stats"]
734
+ if stats.get("data") == "AVAILABLE":
735
+ print(f"Ledger History: {stats['total_entries']} entries, "
736
+ f"{stats['failed_count']} failed, "
737
+ f"{stats['repair_count']} repairs, "
738
+ f"retry rate: {stats['retry_rate']}")
739
+ else:
740
+ print(f"Ledger History: DATA-UNAVAILABLE")
741
+ print()
742
+
743
+ if result["items"]:
744
+ print("Flagged Items:")
745
+ for item in result["items"]:
746
+ if item["flags"]:
747
+ print(f" [{item['id']}] {item['title']}")
748
+ for flag in item["flags"]:
749
+ print(f" - {flag['type']}: {flag['detail']}")
750
+ else:
751
+ print("No risky items detected.")
752
+
753
+ # Exit 0 always (advisory tool, never blocks)
754
+ return 0
755
+ else:
756
+ # Standard repo readiness checks
757
+ result = run_checks(root_dir, state_dir, config)
758
+
759
+ if output_format == "json":
760
+ print(json.dumps(result, indent=2))
761
+ else:
762
+ # Text format: numbered list
763
+ print("Wave preflight checks:")
764
+ for i, check in enumerate(result["checks"], 1):
765
+ status = "PASS" if check["ok"] else "FAIL"
766
+ print(f"{i}. {check['name']}: {status}")
767
+ if check["detail"]:
768
+ print(f" {check['detail']}")
769
+
770
+ if result["ready"]:
771
+ print("\nPASS: Ready for wave")
772
+ else:
773
+ print("\nFAIL: Not ready for wave (see failures above)")
774
+
775
+ return 0 if result["ready"] else 1
776
+
777
+
778
+ if __name__ == "__main__":
779
+ sys.exit(main())