@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,512 @@
|
|
|
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
|
+
import time
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
from common import get_state_dir, check_heartbeat_staleness
|
|
46
|
+
except ImportError:
|
|
47
|
+
from tools.common import get_state_dir, check_heartbeat_staleness
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
import halt
|
|
51
|
+
except ImportError:
|
|
52
|
+
from tools import halt
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config(root_dir=None):
|
|
56
|
+
"""Load aesop.config.json from root, return dict (or {} if absent/bad)."""
|
|
57
|
+
if root_dir is None:
|
|
58
|
+
root_dir = Path.cwd()
|
|
59
|
+
else:
|
|
60
|
+
root_dir = Path(root_dir)
|
|
61
|
+
|
|
62
|
+
config_file = root_dir / "aesop.config.json"
|
|
63
|
+
if not config_file.exists():
|
|
64
|
+
return {}
|
|
65
|
+
try:
|
|
66
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
67
|
+
return json.load(f)
|
|
68
|
+
except Exception:
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_state_dir(root_dir=None, config=None):
|
|
73
|
+
"""Resolve state dir: AESOP_STATE_ROOT env > config state_root > ./state."""
|
|
74
|
+
if os.environ.get("AESOP_STATE_ROOT"):
|
|
75
|
+
return Path(os.environ["AESOP_STATE_ROOT"])
|
|
76
|
+
|
|
77
|
+
if root_dir is None:
|
|
78
|
+
root_dir = Path.cwd()
|
|
79
|
+
else:
|
|
80
|
+
root_dir = Path(root_dir)
|
|
81
|
+
|
|
82
|
+
if config is None:
|
|
83
|
+
config = load_config(root_dir)
|
|
84
|
+
|
|
85
|
+
state_root = config.get("state_root") if isinstance(config, dict) else None
|
|
86
|
+
if state_root:
|
|
87
|
+
p = Path(state_root).expanduser()
|
|
88
|
+
if not p.is_absolute():
|
|
89
|
+
p = root_dir / p
|
|
90
|
+
return p
|
|
91
|
+
|
|
92
|
+
return root_dir / "state"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_state_md_phase(state_md_path):
|
|
96
|
+
"""Extract phase from STATE.md heading: ## Phase: `<phase>` ...
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
str or None: the phase name, or None if not found/unparseable.
|
|
100
|
+
"""
|
|
101
|
+
if not state_md_path.exists():
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
content = state_md_path.read_text(encoding="utf-8")
|
|
106
|
+
# Match: ## Phase: `<phase>` ...
|
|
107
|
+
match = re.search(r'^##\s+Phase:\s+`([^`]+)`', content, re.MULTILINE)
|
|
108
|
+
if match:
|
|
109
|
+
return match.group(1)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_orchestrator_status_phase(status_json_path):
|
|
117
|
+
"""Extract phase from orchestrator-status.json.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
str or None: the phase field, or None if not found/unparseable.
|
|
121
|
+
"""
|
|
122
|
+
if not status_json_path.exists():
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
data = json.loads(status_json_path.read_text(encoding="utf-8"))
|
|
127
|
+
return data.get("phase")
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_orchestrator_status_freshness(status_json_path, threshold_s):
|
|
135
|
+
"""Check if orchestrator-status.json is fresh based on updated_at timestamp.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
status_json_path: Path to orchestrator-status.json
|
|
139
|
+
threshold_s: Staleness threshold in seconds
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Tuple of (is_stale, age_s, info):
|
|
143
|
+
is_stale (bool): True if file missing, unreadable, or age >= threshold_s
|
|
144
|
+
age_s (int): Age in seconds (0 if file missing/unreadable)
|
|
145
|
+
info (str or None): Descriptive message if stale/missing, None if fresh
|
|
146
|
+
"""
|
|
147
|
+
if not status_json_path.exists():
|
|
148
|
+
return True, 0, "orchestrator-status.json file missing"
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
data = json.loads(status_json_path.read_text(encoding="utf-8"))
|
|
152
|
+
updated_at = data.get("updated_at")
|
|
153
|
+
if not updated_at:
|
|
154
|
+
return True, 0, "orchestrator-status.json missing updated_at field"
|
|
155
|
+
|
|
156
|
+
# Parse ISO 8601 timestamp (handle both "Z" and "+00:00" timezone formats)
|
|
157
|
+
import datetime
|
|
158
|
+
# Normalize Z suffix to +00:00 for fromisoformat compatibility
|
|
159
|
+
normalized_ts = updated_at.replace("Z", "+00:00")
|
|
160
|
+
updated_dt = datetime.datetime.fromisoformat(normalized_ts)
|
|
161
|
+
timestamp = updated_dt.timestamp()
|
|
162
|
+
|
|
163
|
+
age_seconds = int(time.time()) - int(timestamp)
|
|
164
|
+
|
|
165
|
+
# Check for far-future timestamp (clock skew beyond tolerance)
|
|
166
|
+
# More than 120s in the future is treated as stale, not clamped-to-fresh
|
|
167
|
+
if age_seconds < -120:
|
|
168
|
+
return True, 0, "orchestrator-status timestamp in future (clock skew)"
|
|
169
|
+
|
|
170
|
+
# Clamp small negative ages to 0 (normal clock skew recovery)
|
|
171
|
+
age_seconds = max(0, age_seconds)
|
|
172
|
+
|
|
173
|
+
if age_seconds >= threshold_s:
|
|
174
|
+
return True, age_seconds, f"orchestrator-status stale ({age_seconds}s >= {threshold_s}s)"
|
|
175
|
+
|
|
176
|
+
return False, age_seconds, None
|
|
177
|
+
except Exception as e:
|
|
178
|
+
return True, 0, f"orchestrator-status.json unreadable: {e}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def is_git_repo(root_dir):
|
|
182
|
+
"""Check if root_dir is a git repository."""
|
|
183
|
+
git_dir = Path(root_dir) / ".git"
|
|
184
|
+
return git_dir.exists()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_current_branch(root_dir):
|
|
188
|
+
"""Get current git branch name.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
str or None: branch name, or None if unable to determine (e.g., not a repo, detached HEAD).
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
result = subprocess.run(
|
|
195
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
196
|
+
cwd=root_dir,
|
|
197
|
+
capture_output=True,
|
|
198
|
+
text=True,
|
|
199
|
+
timeout=5,
|
|
200
|
+
)
|
|
201
|
+
if result.returncode == 0:
|
|
202
|
+
branch = result.stdout.strip()
|
|
203
|
+
if branch == "HEAD":
|
|
204
|
+
# Detached HEAD
|
|
205
|
+
return None
|
|
206
|
+
return branch
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def is_working_tree_clean(root_dir):
|
|
213
|
+
"""Check if git working tree is clean (ignores untracked files).
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
(bool, str or None): (is_clean, detail_msg if not clean)
|
|
217
|
+
"""
|
|
218
|
+
try:
|
|
219
|
+
result = subprocess.run(
|
|
220
|
+
["git", "status", "--porcelain"],
|
|
221
|
+
cwd=root_dir,
|
|
222
|
+
capture_output=True,
|
|
223
|
+
text=True,
|
|
224
|
+
timeout=5,
|
|
225
|
+
)
|
|
226
|
+
if result.returncode == 0:
|
|
227
|
+
output = result.stdout.strip()
|
|
228
|
+
if not output:
|
|
229
|
+
return True, None
|
|
230
|
+
# Filter out untracked files (lines starting with ??)
|
|
231
|
+
# and only consider tracked file changes (M, A, D, etc.)
|
|
232
|
+
dirty_lines = [
|
|
233
|
+
line for line in output.split("\n")
|
|
234
|
+
if line and not line.startswith("??")
|
|
235
|
+
]
|
|
236
|
+
if not dirty_lines:
|
|
237
|
+
return True, None
|
|
238
|
+
# List first few dirty files
|
|
239
|
+
lines = dirty_lines[:3]
|
|
240
|
+
detail = "uncommitted changes: " + "; ".join(lines)
|
|
241
|
+
if len(dirty_lines) > 3:
|
|
242
|
+
detail += f" (+{len(dirty_lines) - 3} more)"
|
|
243
|
+
return False, detail
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
return False, "unable to check git status"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def can_import_secret_scan():
|
|
250
|
+
"""Check if secret_scan can be imported.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
(bool, str or None): (importable, detail_msg if not)
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
import secret_scan
|
|
257
|
+
return True, None
|
|
258
|
+
except ImportError as e:
|
|
259
|
+
return False, str(e)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def run_checks(root_dir=None, state_dir=None, config=None):
|
|
263
|
+
"""Run all preflight checks.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
root_dir: repo root (inferred from cwd if None)
|
|
267
|
+
state_dir: state dir (resolved if None)
|
|
268
|
+
config: aesop.config.json dict (loaded if None)
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
dict: {
|
|
272
|
+
ready: bool (all checks pass/warn),
|
|
273
|
+
checks: [
|
|
274
|
+
{name: str, ok: bool, detail: str},
|
|
275
|
+
...
|
|
276
|
+
]
|
|
277
|
+
}
|
|
278
|
+
"""
|
|
279
|
+
if root_dir is None:
|
|
280
|
+
root_dir = Path.cwd()
|
|
281
|
+
else:
|
|
282
|
+
root_dir = Path(root_dir)
|
|
283
|
+
|
|
284
|
+
if config is None:
|
|
285
|
+
config = load_config(root_dir)
|
|
286
|
+
|
|
287
|
+
if state_dir is None:
|
|
288
|
+
state_dir = resolve_state_dir(root_dir, config)
|
|
289
|
+
else:
|
|
290
|
+
state_dir = Path(state_dir)
|
|
291
|
+
|
|
292
|
+
checks = []
|
|
293
|
+
|
|
294
|
+
# Check 1: Git repo exists
|
|
295
|
+
is_repo = is_git_repo(root_dir)
|
|
296
|
+
checks.append({
|
|
297
|
+
"name": "Git repository",
|
|
298
|
+
"ok": is_repo,
|
|
299
|
+
"detail": "repo found" if is_repo else "not a git repo",
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
if not is_repo:
|
|
303
|
+
# Can't proceed without a repo
|
|
304
|
+
return {"ready": False, "checks": checks}
|
|
305
|
+
|
|
306
|
+
# Check 2: On a feature branch (not main/master)
|
|
307
|
+
branch = get_current_branch(root_dir)
|
|
308
|
+
if branch is None:
|
|
309
|
+
on_feature_branch = False
|
|
310
|
+
detail = "detached HEAD or unable to determine branch"
|
|
311
|
+
else:
|
|
312
|
+
on_feature_branch = branch not in ("main", "master")
|
|
313
|
+
detail = f"branch={branch}"
|
|
314
|
+
|
|
315
|
+
checks.append({
|
|
316
|
+
"name": "Feature branch (not main/master)",
|
|
317
|
+
"ok": on_feature_branch,
|
|
318
|
+
"detail": detail,
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
# Check 3: Working tree clean
|
|
322
|
+
clean, dirty_detail = is_working_tree_clean(root_dir)
|
|
323
|
+
checks.append({
|
|
324
|
+
"name": "Working tree clean",
|
|
325
|
+
"ok": clean,
|
|
326
|
+
"detail": dirty_detail or "no uncommitted changes",
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
# Check 4: No .HALT sentinel
|
|
330
|
+
is_halted = halt.is_halted(state_dir)
|
|
331
|
+
halt_detail = "not halted"
|
|
332
|
+
if is_halted:
|
|
333
|
+
halt_info = halt.get_halt_info(state_dir)
|
|
334
|
+
halt_detail = halt_info.get("reason", "halted") if halt_info else "halted"
|
|
335
|
+
|
|
336
|
+
checks.append({
|
|
337
|
+
"name": "No .HALT sentinel",
|
|
338
|
+
"ok": not is_halted,
|
|
339
|
+
"detail": halt_detail,
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
# Check 5: STATE.md phase vs orchestrator-status.json phase (warn-level)
|
|
343
|
+
# Genuine comparison: only warn if both phases exist and differ
|
|
344
|
+
state_md_path = root_dir / "STATE.md"
|
|
345
|
+
status_json_path = state_dir / "orchestrator-status.json"
|
|
346
|
+
|
|
347
|
+
state_phase = parse_state_md_phase(state_md_path)
|
|
348
|
+
status_phase = parse_orchestrator_status_phase(status_json_path)
|
|
349
|
+
|
|
350
|
+
# Determine phase drift: only if both are defined and differ
|
|
351
|
+
drift_detected = False
|
|
352
|
+
if state_phase is not None and status_phase is not None:
|
|
353
|
+
if state_phase != status_phase:
|
|
354
|
+
# Drift detected: both phases exist but differ
|
|
355
|
+
drift_detected = True
|
|
356
|
+
phase_detail = f"STATE.md={state_phase}, status.json={status_phase} [WARN: drift detected]"
|
|
357
|
+
else:
|
|
358
|
+
# Phases match
|
|
359
|
+
phase_detail = f"STATE.md={state_phase}, status.json={status_phase}"
|
|
360
|
+
else:
|
|
361
|
+
# One or both phases missing (not yet ready or not applicable)
|
|
362
|
+
phase_detail = f"STATE.md={state_phase}, status.json={status_phase}"
|
|
363
|
+
|
|
364
|
+
# Phase drift check: warning-level (drift is reported but does not block)
|
|
365
|
+
# The check always passes (phase_ok = True), but drift detail is visible in output
|
|
366
|
+
# This makes the check non-vacuous: drift is detected and reported, but doesn't block
|
|
367
|
+
phase_ok = True
|
|
368
|
+
|
|
369
|
+
checks.append({
|
|
370
|
+
"name": "STATE.md phase consistent with orchestrator-status.json (warning-level)",
|
|
371
|
+
"ok": phase_ok,
|
|
372
|
+
"detail": phase_detail,
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
# Check 6: Heartbeats and status freshness
|
|
376
|
+
# Check watchdog heartbeat (200s threshold) and orchestrator-status.json (300s)
|
|
377
|
+
heartbeat_details = []
|
|
378
|
+
all_heartbeats_ok = True
|
|
379
|
+
|
|
380
|
+
watchdog_hb = state_dir / ".watchdog-heartbeat"
|
|
381
|
+
is_stale, age, info = check_heartbeat_staleness(watchdog_hb, 200)
|
|
382
|
+
hb_name = "watchdog"
|
|
383
|
+
if is_stale:
|
|
384
|
+
all_heartbeats_ok = False
|
|
385
|
+
heartbeat_details.append(f"{hb_name}: {info} (age={age}s)")
|
|
386
|
+
else:
|
|
387
|
+
heartbeat_details.append(f"{hb_name}: fresh (age={age}s)")
|
|
388
|
+
|
|
389
|
+
# Check orchestrator-status.json updated_at field (300s threshold)
|
|
390
|
+
status_json_path = state_dir / "orchestrator-status.json"
|
|
391
|
+
is_stale, age, info = check_orchestrator_status_freshness(status_json_path, 300)
|
|
392
|
+
status_name = "orchestrator-status"
|
|
393
|
+
if is_stale:
|
|
394
|
+
all_heartbeats_ok = False
|
|
395
|
+
heartbeat_details.append(f"{status_name}: {info} (age={age}s)")
|
|
396
|
+
else:
|
|
397
|
+
heartbeat_details.append(f"{status_name}: fresh (age={age}s)")
|
|
398
|
+
|
|
399
|
+
checks.append({
|
|
400
|
+
"name": "Heartbeats and orchestrator status fresh",
|
|
401
|
+
"ok": all_heartbeats_ok,
|
|
402
|
+
"detail": "; ".join(heartbeat_details),
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
# Check 7: state/tracker.json parses as JSON
|
|
406
|
+
tracker_json_path = state_dir / "tracker.json"
|
|
407
|
+
tracker_ok = False
|
|
408
|
+
tracker_detail = "tracker.json not found"
|
|
409
|
+
|
|
410
|
+
if tracker_json_path.exists():
|
|
411
|
+
try:
|
|
412
|
+
json.loads(tracker_json_path.read_text(encoding="utf-8"))
|
|
413
|
+
tracker_ok = True
|
|
414
|
+
tracker_detail = "valid JSON"
|
|
415
|
+
except json.JSONDecodeError as e:
|
|
416
|
+
tracker_detail = f"invalid JSON: {e}"
|
|
417
|
+
except Exception as e:
|
|
418
|
+
tracker_detail = f"unreadable: {e}"
|
|
419
|
+
|
|
420
|
+
checks.append({
|
|
421
|
+
"name": "state/tracker.json parses as JSON",
|
|
422
|
+
"ok": tracker_ok,
|
|
423
|
+
"detail": tracker_detail,
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
# Check 8: secret_scan importable
|
|
427
|
+
can_import, import_detail = can_import_secret_scan()
|
|
428
|
+
checks.append({
|
|
429
|
+
"name": "secret_scan importable",
|
|
430
|
+
"ok": can_import,
|
|
431
|
+
"detail": import_detail or "importable",
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
# Determine overall readiness: pass if all checks ok (warnings don't fail)
|
|
435
|
+
# For now, all checks must pass (warnings are just flags in detail)
|
|
436
|
+
all_ok = all(c["ok"] for c in checks)
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
"ready": all_ok,
|
|
440
|
+
"checks": checks,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def main(argv=None):
|
|
445
|
+
"""CLI entry point."""
|
|
446
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
447
|
+
|
|
448
|
+
root_dir = None
|
|
449
|
+
state_dir = None
|
|
450
|
+
output_format = "text"
|
|
451
|
+
|
|
452
|
+
# Parse arguments
|
|
453
|
+
i = 0
|
|
454
|
+
while i < len(argv):
|
|
455
|
+
arg = argv[i]
|
|
456
|
+
if arg == "--root":
|
|
457
|
+
i += 1
|
|
458
|
+
if i < len(argv):
|
|
459
|
+
root_dir = argv[i]
|
|
460
|
+
i += 1
|
|
461
|
+
elif arg.startswith("--root="):
|
|
462
|
+
root_dir = arg[len("--root="):]
|
|
463
|
+
i += 1
|
|
464
|
+
elif arg == "--state-root":
|
|
465
|
+
i += 1
|
|
466
|
+
if i < len(argv):
|
|
467
|
+
state_dir = argv[i]
|
|
468
|
+
i += 1
|
|
469
|
+
elif arg.startswith("--state-root="):
|
|
470
|
+
state_dir = arg[len("--state-root="):]
|
|
471
|
+
i += 1
|
|
472
|
+
elif arg == "--json":
|
|
473
|
+
output_format = "json"
|
|
474
|
+
i += 1
|
|
475
|
+
else:
|
|
476
|
+
print(f"Unknown argument: {arg}", file=sys.stderr)
|
|
477
|
+
return 2
|
|
478
|
+
|
|
479
|
+
if root_dir is None:
|
|
480
|
+
root_dir = Path.cwd()
|
|
481
|
+
else:
|
|
482
|
+
root_dir = Path(root_dir)
|
|
483
|
+
|
|
484
|
+
config = load_config(root_dir)
|
|
485
|
+
if state_dir is None:
|
|
486
|
+
state_dir = resolve_state_dir(root_dir, config)
|
|
487
|
+
else:
|
|
488
|
+
state_dir = Path(state_dir)
|
|
489
|
+
|
|
490
|
+
result = run_checks(root_dir, state_dir, config)
|
|
491
|
+
|
|
492
|
+
if output_format == "json":
|
|
493
|
+
print(json.dumps(result, indent=2))
|
|
494
|
+
else:
|
|
495
|
+
# Text format: numbered list
|
|
496
|
+
print("Wave preflight checks:")
|
|
497
|
+
for i, check in enumerate(result["checks"], 1):
|
|
498
|
+
status = "PASS" if check["ok"] else "FAIL"
|
|
499
|
+
print(f"{i}. {check['name']}: {status}")
|
|
500
|
+
if check["detail"]:
|
|
501
|
+
print(f" {check['detail']}")
|
|
502
|
+
|
|
503
|
+
if result["ready"]:
|
|
504
|
+
print("\nPASS: Ready for wave")
|
|
505
|
+
else:
|
|
506
|
+
print("\nFAIL: Not ready for wave (see failures above)")
|
|
507
|
+
|
|
508
|
+
return 0 if result["ready"] else 1
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
if __name__ == "__main__":
|
|
512
|
+
sys.exit(main())
|