@matt82198/aesop 0.1.0-rc.1 → 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 +101 -2
- package/README.md +66 -15
- 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,394 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLAUDE.md semantic drift detector.
|
|
3
|
+
|
|
4
|
+
Detects semantic drift between documentation claims and disk reality:
|
|
5
|
+
1. Files/commands referenced in domain CLAUDE.md that don't exist
|
|
6
|
+
2. Domain dirs on disk missing from root map
|
|
7
|
+
3. Root-map entries whose dirs are gone
|
|
8
|
+
4. Documented CLI flags absent from --help
|
|
9
|
+
|
|
10
|
+
Deterministic checks only (no LLM). Per-domain report.
|
|
11
|
+
Exit: 0=clean, 1=drift found. Supports --json.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Set, Tuple, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_domain_map(root_claude_content: str) -> Dict[str, Optional[str]]:
|
|
24
|
+
"""Extract domain entries from root CLAUDE.md.
|
|
25
|
+
|
|
26
|
+
Returns dict of domain_name -> description (or None).
|
|
27
|
+
Only entries ending with '/' are recognized as domains.
|
|
28
|
+
"""
|
|
29
|
+
domains = {}
|
|
30
|
+
# Match lines like: - **domain/** — description
|
|
31
|
+
pattern = r"-\s+\*\*([a-zA-Z0-9_\-]+)/\*\*\s+—\s+([^\n]+)"
|
|
32
|
+
for match in re.finditer(pattern, root_claude_content):
|
|
33
|
+
domain_name = match.group(1)
|
|
34
|
+
description = match.group(2).strip()
|
|
35
|
+
domains[domain_name] = description
|
|
36
|
+
return domains
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_cli_specs(tool_name: str, tool_line: str) -> Set[str]:
|
|
40
|
+
"""Extract CLI flag specifications from a tool index line.
|
|
41
|
+
|
|
42
|
+
Parses lines like:
|
|
43
|
+
`tool.py` — description; CLI: `--flag1 --flag2 <arg>` | `--flag3`
|
|
44
|
+
|
|
45
|
+
Returns set of flag names (e.g., {"--flag1", "--flag2", "--flag3"}).
|
|
46
|
+
"""
|
|
47
|
+
flags = set()
|
|
48
|
+
|
|
49
|
+
# Look for CLI: ... section (may span to end of line or be cut by another section)
|
|
50
|
+
cli_match = re.search(r"CLI:\s*(.+?)(?:$)", tool_line)
|
|
51
|
+
if not cli_match:
|
|
52
|
+
return flags
|
|
53
|
+
|
|
54
|
+
cli_text = cli_match.group(1)
|
|
55
|
+
|
|
56
|
+
# Extract all --flag patterns (including those with arguments like <arg>)
|
|
57
|
+
# Matches: --flag-name, --flag, --flag <arg>, --flag <path>, etc.
|
|
58
|
+
# Handle pipes and backticks as delimiters
|
|
59
|
+
flag_pattern = r"--[a-zA-Z0-9\-]+"
|
|
60
|
+
for match in re.finditer(flag_pattern, cli_text):
|
|
61
|
+
flag = match.group(0)
|
|
62
|
+
flags.add(flag)
|
|
63
|
+
|
|
64
|
+
return flags
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_domain_dirs_exist(
|
|
68
|
+
domains: Dict[str, Optional[str]], repo_root: Path
|
|
69
|
+
) -> List[Dict[str, str]]:
|
|
70
|
+
"""Check that all mapped domains exist as directories.
|
|
71
|
+
|
|
72
|
+
Returns findings for domains that don't exist.
|
|
73
|
+
|
|
74
|
+
Skips special runtime-only domains that are intentionally missing.
|
|
75
|
+
"""
|
|
76
|
+
findings = []
|
|
77
|
+
|
|
78
|
+
# Domains that are documented but intentionally runtime-only (git-ignored)
|
|
79
|
+
runtime_only_domains = {"state"}
|
|
80
|
+
|
|
81
|
+
for domain_name in domains:
|
|
82
|
+
# Skip runtime-only domains
|
|
83
|
+
if domain_name in runtime_only_domains:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
domain_path = repo_root / domain_name
|
|
87
|
+
if not domain_path.is_dir():
|
|
88
|
+
findings.append({
|
|
89
|
+
"type": "missing-domain-dir",
|
|
90
|
+
"domain": domain_name,
|
|
91
|
+
"message": f"Domain '{domain_name}/' in root map but not on disk",
|
|
92
|
+
})
|
|
93
|
+
return findings
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_root_map_complete(
|
|
97
|
+
mapped_domains: Dict[str, Optional[str]], repo_root: Path
|
|
98
|
+
) -> List[Dict[str, str]]:
|
|
99
|
+
"""Check for domains on disk that are missing from root map.
|
|
100
|
+
|
|
101
|
+
Ignores hidden dirs, special dirs (assets, state, .git, etc.).
|
|
102
|
+
"""
|
|
103
|
+
findings = []
|
|
104
|
+
|
|
105
|
+
# Dirs to ignore (not domains)
|
|
106
|
+
ignore_dirs = {
|
|
107
|
+
"assets", "state", ".git", "node_modules", ".github",
|
|
108
|
+
".pytest_cache", "__pycache__", ".venv", "venv",
|
|
109
|
+
"dist", "build", ".vscode", ".idea", "conductor3"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for item in repo_root.iterdir():
|
|
113
|
+
# Skip non-dirs
|
|
114
|
+
if not item.is_dir():
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Skip ignored dirs
|
|
118
|
+
if item.name in ignore_dirs or item.name.startswith("."):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Check if this dir is in the mapped domains
|
|
122
|
+
if item.name not in mapped_domains:
|
|
123
|
+
findings.append({
|
|
124
|
+
"type": "unmapped-domain-dir",
|
|
125
|
+
"domain": item.name,
|
|
126
|
+
"message": f"Domain directory '{item.name}/' on disk but not in root map",
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
return findings
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def check_file_references(
|
|
133
|
+
domain_name: str, domain_dir: Path, repo_root: Path
|
|
134
|
+
) -> List[Dict[str, str]]:
|
|
135
|
+
"""Check that files referenced in a domain CLAUDE.md exist.
|
|
136
|
+
|
|
137
|
+
Ignores runtime artifacts (state/*, BRIEF.md, etc.).
|
|
138
|
+
"""
|
|
139
|
+
findings = []
|
|
140
|
+
|
|
141
|
+
# Runtime artifacts allowlist (matches claudemd_lint.py)
|
|
142
|
+
runtime_patterns = [
|
|
143
|
+
r"^\.\.?/state/",
|
|
144
|
+
r"heartbeat",
|
|
145
|
+
r"^BRIEF\.md$",
|
|
146
|
+
r"^PROPOSALS\.md$",
|
|
147
|
+
r"^BUILDLOG\.md$",
|
|
148
|
+
r"^MEMORY\.md$",
|
|
149
|
+
r"^STATE\.md$",
|
|
150
|
+
r"^CLAUDE\.md$",
|
|
151
|
+
r"^SKILL\.md$",
|
|
152
|
+
r"^OUTCOMES-LEDGER\.md$",
|
|
153
|
+
r"^tracker\.json$",
|
|
154
|
+
r"^ACTIONS\.log$",
|
|
155
|
+
r"^\./state/",
|
|
156
|
+
r"^state/",
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
def is_runtime_artifact(ref: str) -> bool:
|
|
160
|
+
for pattern in runtime_patterns:
|
|
161
|
+
if re.search(pattern, ref, re.IGNORECASE):
|
|
162
|
+
return True
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
# Extract file references from CLAUDE.md
|
|
166
|
+
claudemd_path = domain_dir / "CLAUDE.md"
|
|
167
|
+
if not claudemd_path.exists():
|
|
168
|
+
# No CLAUDE.md in this domain, nothing to check
|
|
169
|
+
return findings
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
content = claudemd_path.read_text(encoding="utf-8")
|
|
173
|
+
except (IOError, UnicodeDecodeError):
|
|
174
|
+
# Can't read the file
|
|
175
|
+
return findings
|
|
176
|
+
|
|
177
|
+
# Extract path references (same regex as claudemd_lint.py)
|
|
178
|
+
pattern = r"(?:[`'\"])?([a-zA-Z0-9_.][a-zA-Z0-9_./\-]*\.(?:md|py|sh|mjs))(?:[`'\"])?"
|
|
179
|
+
for match in re.finditer(pattern, content):
|
|
180
|
+
ref = match.group(1)
|
|
181
|
+
|
|
182
|
+
# Filter false positives (same filters as claudemd_lint.py)
|
|
183
|
+
if len(ref) <= 2 or ref.startswith("*"):
|
|
184
|
+
continue
|
|
185
|
+
if ref.startswith("/") or "~/" in ref:
|
|
186
|
+
continue
|
|
187
|
+
if ref.startswith(".") and "/" in ref and not ref.startswith("./"):
|
|
188
|
+
continue
|
|
189
|
+
if "/path/to/" in ref or ref.startswith("path/to/"):
|
|
190
|
+
continue
|
|
191
|
+
if re.match(r"^[A-Z_]+/", ref):
|
|
192
|
+
continue
|
|
193
|
+
if ref.count(".") > 2 or "/" not in ref:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Skip compound references like "CLAUDE.md/STATE.md" (documentation patterns, not file refs)
|
|
197
|
+
# These are patterns listing multiple files separated by "/" (e.g., "FILE1.md/FILE2.md")
|
|
198
|
+
parts = ref.split("/")
|
|
199
|
+
if len(parts) > 1 and all(p.endswith((".md", ".py", ".sh", ".mjs")) for p in parts):
|
|
200
|
+
# This looks like a documentation pattern listing multiple files, not a path
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
# Skip runtime artifacts
|
|
204
|
+
if is_runtime_artifact(ref):
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# Try to resolve the reference
|
|
208
|
+
target = domain_dir / ref
|
|
209
|
+
if not target.exists():
|
|
210
|
+
target = repo_root / ref
|
|
211
|
+
if not target.exists():
|
|
212
|
+
findings.append({
|
|
213
|
+
"type": "phantom-file-reference",
|
|
214
|
+
"domain": domain_name,
|
|
215
|
+
"message": f"{domain_name}/CLAUDE.md references non-existent '{ref}'",
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
return findings
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def check_cli_flags(cli_specs: Dict[str, Set[str]], repo_root: Path) -> List[Dict[str, str]]:
|
|
222
|
+
"""Check that documented CLI flags exist in tool --help.
|
|
223
|
+
|
|
224
|
+
Calls each tool with --help and verifies documented flags appear.
|
|
225
|
+
"""
|
|
226
|
+
findings = []
|
|
227
|
+
|
|
228
|
+
for tool_name, flags in cli_specs.items():
|
|
229
|
+
if not flags:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Find the tool
|
|
233
|
+
tool_path = repo_root / "tools" / tool_name
|
|
234
|
+
if not tool_path.exists():
|
|
235
|
+
# Tool doesn't exist, skip CLI check (file check will catch it)
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# Try to run tool --help
|
|
239
|
+
try:
|
|
240
|
+
result = subprocess.run(
|
|
241
|
+
[str(tool_path), "--help"],
|
|
242
|
+
capture_output=True,
|
|
243
|
+
text=True,
|
|
244
|
+
timeout=5,
|
|
245
|
+
)
|
|
246
|
+
help_text = result.stdout + result.stderr
|
|
247
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
248
|
+
# Can't run the tool, skip
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
# Check if each documented flag appears in help
|
|
252
|
+
for flag in flags:
|
|
253
|
+
if flag not in help_text:
|
|
254
|
+
findings.append({
|
|
255
|
+
"type": "missing-cli-flag",
|
|
256
|
+
"domain": "tools",
|
|
257
|
+
"message": f"{tool_name}: documented flag '{flag}' not in --help output",
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
return findings
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def extract_tool_index(tools_claudemd: str) -> Dict[str, Set[str]]:
|
|
264
|
+
"""Extract tool names and their CLI specs from tools/CLAUDE.md.
|
|
265
|
+
|
|
266
|
+
Returns dict mapping tool_name -> set of flags.
|
|
267
|
+
"""
|
|
268
|
+
cli_specs = {}
|
|
269
|
+
|
|
270
|
+
# Find the "Tool index" section
|
|
271
|
+
index_match = re.search(r"## Tool index.*?\n(.*?)(?=\n## |\Z)", tools_claudemd, re.DOTALL)
|
|
272
|
+
if not index_match:
|
|
273
|
+
return cli_specs
|
|
274
|
+
|
|
275
|
+
index_text = index_match.group(1)
|
|
276
|
+
|
|
277
|
+
# Match tool lines: `tool.py` — description with optional CLI:
|
|
278
|
+
# We're looking for lines like: - `name.py` — description; CLI: `...`
|
|
279
|
+
tool_pattern = r"-\s+`([a-zA-Z0-9_\-]+\.(py|sh|mjs))`\s+—\s+([^\n]+)"
|
|
280
|
+
|
|
281
|
+
for match in re.finditer(tool_pattern, index_text):
|
|
282
|
+
tool_name = match.group(1)
|
|
283
|
+
description = match.group(3)
|
|
284
|
+
|
|
285
|
+
flags = extract_cli_specs(tool_name, description)
|
|
286
|
+
if flags:
|
|
287
|
+
cli_specs[tool_name] = flags
|
|
288
|
+
|
|
289
|
+
return cli_specs
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def run_drift_check(repo_root: Path) -> List[Dict[str, str]]:
|
|
293
|
+
"""Run full drift check.
|
|
294
|
+
|
|
295
|
+
Returns list of finding dicts with 'type', 'domain', 'message'.
|
|
296
|
+
Exit: 0=clean, 1=drift found.
|
|
297
|
+
"""
|
|
298
|
+
findings = []
|
|
299
|
+
|
|
300
|
+
# Read root CLAUDE.md
|
|
301
|
+
root_claude_path = repo_root / "CLAUDE.md"
|
|
302
|
+
if not root_claude_path.exists():
|
|
303
|
+
findings.append({
|
|
304
|
+
"type": "missing-root-claudemd",
|
|
305
|
+
"domain": "root",
|
|
306
|
+
"message": "Root CLAUDE.md not found",
|
|
307
|
+
})
|
|
308
|
+
return findings
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
root_claude_content = root_claude_path.read_text(encoding="utf-8")
|
|
312
|
+
except (IOError, UnicodeDecodeError) as e:
|
|
313
|
+
findings.append({
|
|
314
|
+
"type": "root-claudemd-read-error",
|
|
315
|
+
"domain": "root",
|
|
316
|
+
"message": f"Failed to read root CLAUDE.md: {e}",
|
|
317
|
+
})
|
|
318
|
+
return findings
|
|
319
|
+
|
|
320
|
+
# Extract domain map
|
|
321
|
+
domains = extract_domain_map(root_claude_content)
|
|
322
|
+
|
|
323
|
+
# Check 1: domain dirs exist
|
|
324
|
+
findings.extend(check_domain_dirs_exist(domains, repo_root))
|
|
325
|
+
|
|
326
|
+
# Check 2: root map is complete
|
|
327
|
+
findings.extend(check_root_map_complete(domains, repo_root))
|
|
328
|
+
|
|
329
|
+
# Check 3: file references in each domain CLAUDE.md
|
|
330
|
+
for domain_name in domains:
|
|
331
|
+
domain_dir = repo_root / domain_name
|
|
332
|
+
if domain_dir.is_dir():
|
|
333
|
+
findings.extend(check_file_references(domain_name, domain_dir, repo_root))
|
|
334
|
+
|
|
335
|
+
# Check 4: CLI flags (tools only, for now)
|
|
336
|
+
tools_claudemd_path = repo_root / "tools" / "CLAUDE.md"
|
|
337
|
+
if tools_claudemd_path.exists():
|
|
338
|
+
try:
|
|
339
|
+
tools_claudemd_content = tools_claudemd_path.read_text(encoding="utf-8")
|
|
340
|
+
cli_specs = extract_tool_index(tools_claudemd_content)
|
|
341
|
+
findings.extend(check_cli_flags(cli_specs, repo_root))
|
|
342
|
+
except (IOError, UnicodeDecodeError):
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
return findings
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main():
|
|
349
|
+
"""Main entry point."""
|
|
350
|
+
parser = argparse.ArgumentParser(
|
|
351
|
+
description="CLAUDE.md semantic drift detector — detect drift between docs and disk"
|
|
352
|
+
)
|
|
353
|
+
parser.add_argument(
|
|
354
|
+
"--root",
|
|
355
|
+
type=Path,
|
|
356
|
+
default=Path.cwd(),
|
|
357
|
+
help="Repository root (default: cwd)",
|
|
358
|
+
)
|
|
359
|
+
parser.add_argument(
|
|
360
|
+
"--json",
|
|
361
|
+
action="store_true",
|
|
362
|
+
help="Output as JSON",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
args = parser.parse_args()
|
|
366
|
+
repo_root = args.root.resolve()
|
|
367
|
+
|
|
368
|
+
if not repo_root.exists():
|
|
369
|
+
print(f"Error: repo root {repo_root} does not exist", file=sys.stderr)
|
|
370
|
+
sys.exit(2)
|
|
371
|
+
|
|
372
|
+
findings = run_drift_check(repo_root)
|
|
373
|
+
|
|
374
|
+
if args.json:
|
|
375
|
+
output = {
|
|
376
|
+
"findings": findings,
|
|
377
|
+
"count": len(findings),
|
|
378
|
+
"repo_root": str(repo_root),
|
|
379
|
+
}
|
|
380
|
+
print(json.dumps(output, indent=2))
|
|
381
|
+
else:
|
|
382
|
+
if findings:
|
|
383
|
+
for i, finding in enumerate(findings, 1):
|
|
384
|
+
print(
|
|
385
|
+
f"{i}. [{finding['type']}] {finding['domain']}: {finding['message']}"
|
|
386
|
+
)
|
|
387
|
+
else:
|
|
388
|
+
print("[OK] No semantic drift detected")
|
|
389
|
+
|
|
390
|
+
sys.exit(1 if findings else 0)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
if __name__ == "__main__":
|
|
394
|
+
main()
|