@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,359 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLAUDE.md linter — dogfoods the scope-min invariant.
|
|
3
|
+
|
|
4
|
+
For each */CLAUDE.md in a repo:
|
|
5
|
+
1. DOC-POINTER check — every referenced path ending .md/.py/.sh/.mjs that looks like a
|
|
6
|
+
REPO file (relative, not a runtime artifact) must exist. Distinguishes real repo-doc
|
|
7
|
+
pointers from legitimate references to runtime artifacts (state/**, *heartbeat*,
|
|
8
|
+
BRIEF.md, PROPOSALS.md, BUILDLOG.md, MEMORY.md, STATE.md, OUTCOMES-LEDGER.md, tracker.json).
|
|
9
|
+
2. TEST-CMD check — any `npm run <script>` cited must exist in package.json scripts.
|
|
10
|
+
Flags `pytest` if the repo uses unittest (grep package.json test:py).
|
|
11
|
+
3. Optional — flags files over --max-lines (default 150).
|
|
12
|
+
|
|
13
|
+
Exit: 0=clean, 1=findings. Supports --json flag.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Tuple
|
|
21
|
+
|
|
22
|
+
# Runtime artifact allowlist — these are correctly absent from the tree
|
|
23
|
+
RUNTIME_ARTIFACTS = {
|
|
24
|
+
# State/control files
|
|
25
|
+
"state", # state/ directory
|
|
26
|
+
"BRIEF.md",
|
|
27
|
+
"PROPOSALS.md",
|
|
28
|
+
"BUILDLOG.md",
|
|
29
|
+
"MEMORY.md",
|
|
30
|
+
"STATE.md",
|
|
31
|
+
"OUTCOMES-LEDGER.md",
|
|
32
|
+
"tracker.json",
|
|
33
|
+
"ACTIONS.log",
|
|
34
|
+
".monitor-heartbeat",
|
|
35
|
+
".signal-state.json",
|
|
36
|
+
".HALT",
|
|
37
|
+
".git",
|
|
38
|
+
"node_modules",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Patterns that indicate a runtime artifact
|
|
42
|
+
RUNTIME_PATTERNS = [
|
|
43
|
+
r"^\.\.?/state/", # state/ directory
|
|
44
|
+
r"heartbeat", # *heartbeat*, .monitor-heartbeat, etc.
|
|
45
|
+
r"^BRIEF\.md$",
|
|
46
|
+
r"^PROPOSALS\.md$",
|
|
47
|
+
r"^BUILDLOG\.md$",
|
|
48
|
+
r"^MEMORY\.md$",
|
|
49
|
+
r"^STATE\.md$",
|
|
50
|
+
r"^CLAUDE\.md$",
|
|
51
|
+
r"^SKILL\.md$",
|
|
52
|
+
r"^OUTCOMES-LEDGER\.md$",
|
|
53
|
+
r"^tracker\.json$",
|
|
54
|
+
r"^ACTIONS\.log$",
|
|
55
|
+
r"^\./state/",
|
|
56
|
+
r"^state/",
|
|
57
|
+
# Allow these control files in compound refs like "CLAUDE.md/STATE.md"
|
|
58
|
+
r"CLAUDE\.md(?:/|$)",
|
|
59
|
+
r"STATE\.md(?:/|$)",
|
|
60
|
+
r"SKILL\.md(?:/|$)",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_runtime_artifact(ref: str) -> bool:
|
|
65
|
+
"""Check if a reference is a legitimate runtime artifact."""
|
|
66
|
+
for pattern in RUNTIME_PATTERNS:
|
|
67
|
+
if re.search(pattern, ref, re.IGNORECASE):
|
|
68
|
+
return True
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_path_references(text: str) -> List[str]:
|
|
73
|
+
"""Extract all references to paths ending in .md/.py/.sh/.mjs.
|
|
74
|
+
|
|
75
|
+
Filters out:
|
|
76
|
+
- Example paths starting with /path/to/
|
|
77
|
+
- Environment variable references (VAR_NAME/...)
|
|
78
|
+
- Absolute paths starting with /
|
|
79
|
+
- Home directory references (~/.../...)
|
|
80
|
+
- Non-relative paths
|
|
81
|
+
- Glob patterns (*.something)
|
|
82
|
+
- File type descriptions like ".py/.mjs"
|
|
83
|
+
- Hidden directory references like .claude/ (not repo structure)
|
|
84
|
+
"""
|
|
85
|
+
# Match paths: relative or starting with ./, alphanumeric, /, -, _
|
|
86
|
+
# Also match inline code references like `path/file.md`
|
|
87
|
+
# Note: intentionally NOT matching patterns like "*.test.mjs" (glob)
|
|
88
|
+
pattern = r"(?:[`'\"])?([a-zA-Z0-9_.][a-zA-Z0-9_./\-]*\.(?:md|py|sh|mjs))(?:[`'\"])?"
|
|
89
|
+
matches = re.finditer(pattern, text)
|
|
90
|
+
refs = set()
|
|
91
|
+
for match in matches:
|
|
92
|
+
ref = match.group(1)
|
|
93
|
+
|
|
94
|
+
# Filter out false positives
|
|
95
|
+
if len(ref) <= 2:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Skip glob patterns (starting with *)
|
|
99
|
+
if ref.startswith("*"):
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Skip absolute paths
|
|
103
|
+
if ref.startswith("/"):
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# Skip home directory references (~/...)
|
|
107
|
+
if "~/" in ref:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# Skip hidden directories that don't look like repo structure (./.something/...)
|
|
111
|
+
# These are typically home dir refs like .claude/, .config/, etc.
|
|
112
|
+
if ref.startswith(".") and "/" in ref:
|
|
113
|
+
# Allow only ./ for current dir refs
|
|
114
|
+
if not ref.startswith("./"):
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Skip example paths
|
|
118
|
+
if "/path/to/" in ref or ref.startswith("path/to/"):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Skip env var references (ALLCAPS_NAME/...)
|
|
122
|
+
if re.match(r"^[A-Z_]+/", ref):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Skip file type descriptions like ".py/.mjs" (multiple dots in non-path context)
|
|
126
|
+
if ref.count(".") > 2:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# Skip references that don't have / (not a path)
|
|
130
|
+
if "/" not in ref:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
refs.add(ref)
|
|
134
|
+
|
|
135
|
+
return sorted(refs)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def extract_npm_scripts(text: str) -> List[str]:
|
|
139
|
+
"""Extract all `npm run <script>` references."""
|
|
140
|
+
pattern = r"npm\s+run\s+([a-zA-Z0-9:_\-]+)"
|
|
141
|
+
matches = re.finditer(pattern, text)
|
|
142
|
+
scripts = set()
|
|
143
|
+
for match in matches:
|
|
144
|
+
scripts.add(match.group(1))
|
|
145
|
+
return sorted(scripts)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_package_scripts(repo_root: Path) -> Dict[str, str]:
|
|
149
|
+
"""Load scripts from every package.json in the repo (root + nested, e.g. ui/web).
|
|
150
|
+
|
|
151
|
+
A multi-package repo (aesop has ui/web/package.json for the frontend) means a
|
|
152
|
+
domain doc may legitimately cite a script that lives in a nested package — union
|
|
153
|
+
them so the linter doesn't false-positive on ui/CLAUDE.md's `npm run build`/`dev`.
|
|
154
|
+
"""
|
|
155
|
+
scripts: Dict[str, str] = {}
|
|
156
|
+
for pkg_path in repo_root.rglob("package.json"):
|
|
157
|
+
# skip dependencies' package.json
|
|
158
|
+
if "node_modules" in pkg_path.parts:
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
with open(pkg_path) as f:
|
|
162
|
+
pkg = json.load(f)
|
|
163
|
+
scripts.update(pkg.get("scripts", {}))
|
|
164
|
+
except (json.JSONDecodeError, IOError):
|
|
165
|
+
continue
|
|
166
|
+
return scripts
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def check_test_cmd_match(repo_root: Path) -> Tuple[bool, str]:
|
|
170
|
+
"""Check if repo uses unittest (test:py in package.json uses 'unittest').
|
|
171
|
+
|
|
172
|
+
Returns: (is_using_unittest, test_cmd_value)
|
|
173
|
+
"""
|
|
174
|
+
scripts = get_package_scripts(repo_root)
|
|
175
|
+
test_py = scripts.get("test:py", "")
|
|
176
|
+
is_unittest = "unittest" in test_py
|
|
177
|
+
return is_unittest, test_py
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def lint_claudemd(
|
|
181
|
+
claudemd_path: Path,
|
|
182
|
+
repo_root: Path,
|
|
183
|
+
max_lines: int = 150,
|
|
184
|
+
) -> List[Dict[str, str]]:
|
|
185
|
+
"""Lint a single CLAUDE.md file.
|
|
186
|
+
|
|
187
|
+
Returns list of findings, each a dict with 'type', 'line', 'message'.
|
|
188
|
+
"""
|
|
189
|
+
findings = []
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
content = claudemd_path.read_text(encoding="utf-8")
|
|
193
|
+
except (IOError, UnicodeDecodeError) as e:
|
|
194
|
+
return [{
|
|
195
|
+
"type": "file-read-error",
|
|
196
|
+
"line": "0",
|
|
197
|
+
"message": f"Failed to read {claudemd_path.relative_to(repo_root)}: {e}",
|
|
198
|
+
}]
|
|
199
|
+
|
|
200
|
+
lines = content.split("\n")
|
|
201
|
+
|
|
202
|
+
# Per-file oversize allowance: ui/CLAUDE.md is the documented dense-domain
|
|
203
|
+
# exception (lossless-verified, probe-passed at ~197 lines). Mirrors the same
|
|
204
|
+
# allowance in ~/scripts/compliance_check.py so the two gates agree.
|
|
205
|
+
ALLOWED_OVERSIZE = {"ui/CLAUDE.md": 210} # grew with the dispatch-visibility route/panel (rc.7); still lossless-verified
|
|
206
|
+
rel = str(claudemd_path.relative_to(repo_root)).replace("\\", "/")
|
|
207
|
+
effective_max = ALLOWED_OVERSIZE.get(rel, max_lines)
|
|
208
|
+
|
|
209
|
+
# Check line count
|
|
210
|
+
if len(lines) > effective_max:
|
|
211
|
+
findings.append({
|
|
212
|
+
"type": "line-count",
|
|
213
|
+
"line": str(len(lines)),
|
|
214
|
+
"message": f"{claudemd_path.relative_to(repo_root)}: "
|
|
215
|
+
f"{len(lines)} lines exceeds max {effective_max}",
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
# Check if content endorses pytest but repo uses unittest
|
|
219
|
+
# Exclude false positives where pytest is mentioned in passing or explicitly excluded
|
|
220
|
+
is_unittest, _ = check_test_cmd_match(repo_root)
|
|
221
|
+
if is_unittest:
|
|
222
|
+
content_lower = content.lower()
|
|
223
|
+
# Check for pytest endorsement (not just mention)
|
|
224
|
+
pytest_mentioned = "pytest" in content_lower
|
|
225
|
+
# Check for exclusion phrases that indicate pytest is NOT used
|
|
226
|
+
pytest_excluded = any(phrase in content_lower for phrase in [
|
|
227
|
+
"not pytest",
|
|
228
|
+
"not use pytest",
|
|
229
|
+
"don't use pytest",
|
|
230
|
+
"do not use pytest",
|
|
231
|
+
"uses unittest",
|
|
232
|
+
"use unittest",
|
|
233
|
+
"unittest, not pytest",
|
|
234
|
+
"-m unittest",
|
|
235
|
+
])
|
|
236
|
+
# Flag only if pytest is mentioned AND not explicitly excluded
|
|
237
|
+
if pytest_mentioned and not pytest_excluded:
|
|
238
|
+
findings.append({
|
|
239
|
+
"type": "pytest-vs-unittest",
|
|
240
|
+
"line": "?",
|
|
241
|
+
"message": f"{claudemd_path.relative_to(repo_root)}: "
|
|
242
|
+
f"mentions 'pytest' but repo uses unittest (test:py)",
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
# DOC-POINTER check: find file references
|
|
246
|
+
path_refs = extract_path_references(content)
|
|
247
|
+
|
|
248
|
+
# Get the directory of the CLAUDE.md file for relative resolution
|
|
249
|
+
claudemd_dir = claudemd_path.parent
|
|
250
|
+
|
|
251
|
+
for ref in path_refs:
|
|
252
|
+
# Skip runtime artifacts
|
|
253
|
+
if is_runtime_artifact(ref):
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
# Try to resolve relative to the CLAUDE.md file's directory first
|
|
257
|
+
target = claudemd_dir / ref
|
|
258
|
+
if not target.exists():
|
|
259
|
+
# Fall back to repo root resolution
|
|
260
|
+
target = repo_root / ref
|
|
261
|
+
if not target.exists():
|
|
262
|
+
findings.append({
|
|
263
|
+
"type": "phantom-path",
|
|
264
|
+
"line": "?",
|
|
265
|
+
"message": f"{claudemd_path.relative_to(repo_root)}: "
|
|
266
|
+
f"references non-existent '{ref}'",
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
# TEST-CMD check: npm run scripts
|
|
270
|
+
npm_scripts = extract_npm_scripts(content)
|
|
271
|
+
available_scripts = get_package_scripts(repo_root)
|
|
272
|
+
|
|
273
|
+
for script in npm_scripts:
|
|
274
|
+
if script not in available_scripts:
|
|
275
|
+
findings.append({
|
|
276
|
+
"type": "missing-npm-script",
|
|
277
|
+
"line": "?",
|
|
278
|
+
"message": f"{claudemd_path.relative_to(repo_root)}: "
|
|
279
|
+
f"npm run '{script}' not in package.json scripts",
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
return findings
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main():
|
|
286
|
+
"""Main entry point."""
|
|
287
|
+
import argparse
|
|
288
|
+
|
|
289
|
+
parser = argparse.ArgumentParser(
|
|
290
|
+
description="Lint CLAUDE.md files for integrity"
|
|
291
|
+
)
|
|
292
|
+
parser.add_argument(
|
|
293
|
+
"--root",
|
|
294
|
+
type=Path,
|
|
295
|
+
default=Path.cwd(),
|
|
296
|
+
help="Repository root (default: cwd)",
|
|
297
|
+
)
|
|
298
|
+
parser.add_argument(
|
|
299
|
+
"--max-lines",
|
|
300
|
+
type=int,
|
|
301
|
+
default=150,
|
|
302
|
+
help="Maximum lines per CLAUDE.md (default: 150)",
|
|
303
|
+
)
|
|
304
|
+
parser.add_argument(
|
|
305
|
+
"--json",
|
|
306
|
+
action="store_true",
|
|
307
|
+
help="Output as JSON",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
args = parser.parse_args()
|
|
311
|
+
repo_root = args.root.resolve()
|
|
312
|
+
|
|
313
|
+
if not repo_root.exists():
|
|
314
|
+
print(f"Error: repo root {repo_root} does not exist", file=sys.stderr)
|
|
315
|
+
sys.exit(1)
|
|
316
|
+
|
|
317
|
+
# Find all CLAUDE.md files (recursive, with exclusions for common junk dirs)
|
|
318
|
+
# Exclude: node_modules, .git, dist, worktrees (sibling dirs), .pytest_cache, __pycache__
|
|
319
|
+
claudemd_files = []
|
|
320
|
+
|
|
321
|
+
# Use rglob to find all CLAUDE.md files at any depth
|
|
322
|
+
for claudemd_path in repo_root.rglob("CLAUDE.md"):
|
|
323
|
+
# Exclude paths in problematic directories
|
|
324
|
+
parts = claudemd_path.parts
|
|
325
|
+
if any(part in {"node_modules", ".git", "dist", ".pytest_cache", "__pycache__"} for part in parts):
|
|
326
|
+
continue
|
|
327
|
+
# Exclude worktree paths (parent directory sibling paths like ../aesop-wt-*)
|
|
328
|
+
# This is already handled by only searching within repo_root
|
|
329
|
+
claudemd_files.append(claudemd_path)
|
|
330
|
+
|
|
331
|
+
claudemd_files = sorted(set(claudemd_files))
|
|
332
|
+
|
|
333
|
+
all_findings = []
|
|
334
|
+
for claudemd_path in claudemd_files:
|
|
335
|
+
findings = lint_claudemd(claudemd_path, repo_root, args.max_lines)
|
|
336
|
+
all_findings.extend(findings)
|
|
337
|
+
|
|
338
|
+
if args.json:
|
|
339
|
+
output = {
|
|
340
|
+
"findings": all_findings,
|
|
341
|
+
"count": len(all_findings),
|
|
342
|
+
"repo_root": str(repo_root),
|
|
343
|
+
}
|
|
344
|
+
print(json.dumps(output, indent=2))
|
|
345
|
+
else:
|
|
346
|
+
if all_findings:
|
|
347
|
+
for i, finding in enumerate(all_findings, 1):
|
|
348
|
+
print(
|
|
349
|
+
f"{i}. [{finding['type']}] {finding['message']} "
|
|
350
|
+
f"(line {finding['line']})"
|
|
351
|
+
)
|
|
352
|
+
else:
|
|
353
|
+
print("[OK] No issues found")
|
|
354
|
+
|
|
355
|
+
sys.exit(1 if all_findings else 0)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
main()
|
package/tools/common.py
CHANGED
|
@@ -6,14 +6,26 @@ Functions:
|
|
|
6
6
|
get_state_dir() -> Path
|
|
7
7
|
Resolve state directory from AESOP_STATE_ROOT env var or default to ./state
|
|
8
8
|
|
|
9
|
+
get_state_db_path() -> Path
|
|
10
|
+
Return the canonical SQLite DB path for the event store.
|
|
11
|
+
|
|
9
12
|
check_heartbeat_staleness(hb_file, threshold_s) -> (is_stale, age_s, info)
|
|
10
13
|
Check if a heartbeat file is stale and return staleness, age, and descriptive info
|
|
14
|
+
|
|
15
|
+
Constants:
|
|
16
|
+
STATE_DB_FILENAME: The canonical filename for the event-sourced state DB.
|
|
17
|
+
Multi-instance coordination requires all instances to point to the same file.
|
|
11
18
|
"""
|
|
12
19
|
|
|
13
20
|
import os
|
|
14
21
|
import time
|
|
15
22
|
from pathlib import Path
|
|
16
23
|
|
|
24
|
+
# Canonical filename for the event-sourced state database.
|
|
25
|
+
# Multi-instance requires all instances (including reconcile.py, ui/collectors.py, etc.)
|
|
26
|
+
# to point at the SAME shared file. Previously inconsistent (tracker_events.db vs events.db).
|
|
27
|
+
STATE_DB_FILENAME = "tracker_events.db"
|
|
28
|
+
|
|
17
29
|
|
|
18
30
|
def get_state_dir():
|
|
19
31
|
"""Resolve state directory from env var or current working directory.
|
|
@@ -28,6 +40,18 @@ def get_state_dir():
|
|
|
28
40
|
return Path.cwd() / "state"
|
|
29
41
|
|
|
30
42
|
|
|
43
|
+
def get_state_db_path():
|
|
44
|
+
"""Return the canonical SQLite DB path for the event store.
|
|
45
|
+
|
|
46
|
+
Multi-instance coordination requires all orchestrators to point at the
|
|
47
|
+
SAME shared database file. This function centralizes the DB path resolution.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Path: The canonical path to the state database (state/tracker_events.db).
|
|
51
|
+
"""
|
|
52
|
+
return get_state_dir() / STATE_DB_FILENAME
|
|
53
|
+
|
|
54
|
+
|
|
31
55
|
def check_heartbeat_staleness(hb_file, threshold_s):
|
|
32
56
|
"""Check if a heartbeat file is stale.
|
|
33
57
|
|
|
@@ -41,8 +65,13 @@ def check_heartbeat_staleness(hb_file, threshold_s):
|
|
|
41
65
|
age_s (int): Age in seconds (0 if file missing/unreadable)
|
|
42
66
|
info (str or None): Descriptive message if stale/missing, None if fresh
|
|
43
67
|
"""
|
|
44
|
-
|
|
45
|
-
|
|
68
|
+
try:
|
|
69
|
+
if not hb_file.exists():
|
|
70
|
+
return True, 0, "Heartbeat file missing"
|
|
71
|
+
except OSError:
|
|
72
|
+
# Parent dir unreadable (permissions) — cannot verify, report stale
|
|
73
|
+
# (fail-closed, per the documented contract: unreadable => stale)
|
|
74
|
+
return True, 0, "Heartbeat file unreadable"
|
|
46
75
|
|
|
47
76
|
try:
|
|
48
77
|
content = hb_file.read_text(encoding="utf-8").strip()
|
|
@@ -54,7 +83,14 @@ def check_heartbeat_staleness(hb_file, threshold_s):
|
|
|
54
83
|
return True, 0, "Heartbeat file unreadable"
|
|
55
84
|
|
|
56
85
|
age_seconds = int(time.time()) - timestamp
|
|
57
|
-
|
|
86
|
+
|
|
87
|
+
# Check for future-dated timestamp (clock skew beyond tolerance)
|
|
88
|
+
# More than 120s in the future is treated as stale, not clamped-to-fresh
|
|
89
|
+
if age_seconds < -120:
|
|
90
|
+
return True, 0, "Heartbeat timestamp in future (clock skew)"
|
|
91
|
+
|
|
92
|
+
# Clamp small negative ages to 0 (normal clock skew recovery)
|
|
93
|
+
age_seconds = max(0, age_seconds)
|
|
58
94
|
|
|
59
95
|
if age_seconds >= threshold_s:
|
|
60
96
|
return True, age_seconds, f"Heartbeat stale ({age_seconds}s >= {threshold_s}s)"
|
package/tools/cost_ceiling.py
CHANGED
|
@@ -125,38 +125,64 @@ def read_ledger_total_tokens(state_dir, period="wave"):
|
|
|
125
125
|
def check(spent=None, period="wave", config=None, state_dir=None, trip=True):
|
|
126
126
|
"""Check spend against the configured ceiling for `period`.
|
|
127
127
|
|
|
128
|
-
Returns a dict: {"period", "ceiling", "spent", "exceeded", "tripped"}.
|
|
128
|
+
Returns a dict: {"period", "ceiling", "spent", "exceeded", "tripped", "reason"}.
|
|
129
|
+
|
|
130
|
+
Distinctions:
|
|
131
|
+
- Genuine ceiling breach (ceiling is configured, spent >= ceiling): when trip=True,
|
|
132
|
+
writes persistent .HALT sentinel via halt.halt() and sets tripped=True.
|
|
133
|
+
- Exception during spend computation (ledger read/create failure, etc.): signals
|
|
134
|
+
abort of current wave (exceeded=True) but does NOT write persistent sentinel
|
|
135
|
+
(tripped=False). This preserves fleet availability across transient I/O errors
|
|
136
|
+
(e.g., momentary file lock, disk full) while still aborting the current wave.
|
|
129
137
|
"""
|
|
130
|
-
|
|
131
|
-
config
|
|
132
|
-
|
|
133
|
-
state_dir
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
spent
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
138
|
+
try:
|
|
139
|
+
if config is None:
|
|
140
|
+
config = load_config()
|
|
141
|
+
if state_dir is None:
|
|
142
|
+
state_dir = halt.resolve_state_dir(config=config)
|
|
143
|
+
state_dir = Path(state_dir)
|
|
144
|
+
|
|
145
|
+
ceiling = get_ceiling(config, period)
|
|
146
|
+
|
|
147
|
+
if spent is None:
|
|
148
|
+
spent = read_ledger_total_tokens(state_dir, period=period)
|
|
149
|
+
spent = int(spent)
|
|
150
|
+
|
|
151
|
+
exceeded = ceiling is not None and spent >= ceiling
|
|
152
|
+
tripped = False
|
|
153
|
+
|
|
154
|
+
if exceeded and trip:
|
|
155
|
+
reason = (
|
|
156
|
+
f"cost ceiling exceeded: {period} spend {spent} tokens >= "
|
|
157
|
+
f"ceiling {ceiling} tokens"
|
|
158
|
+
)
|
|
159
|
+
halt.halt(reason, state_dir=state_dir)
|
|
160
|
+
tripped = True
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
"period": period,
|
|
164
|
+
"ceiling": ceiling,
|
|
165
|
+
"spent": spent,
|
|
166
|
+
"exceeded": exceeded,
|
|
167
|
+
"tripped": tripped,
|
|
168
|
+
"reason": None,
|
|
169
|
+
}
|
|
170
|
+
except Exception as e:
|
|
171
|
+
# Exception during spend computation (e.g., ledger read/create failure,
|
|
172
|
+
# file lock, transient I/O error). This is fail-SAFE: abort the current
|
|
173
|
+
# wave (exceeded=True) to prevent runaway work, but do NOT write the
|
|
174
|
+
# persistent .HALT sentinel (tripped=False). The distinction ensures a
|
|
175
|
+
# transient I/O hiccup does not permanently wedge the fleet.
|
|
176
|
+
reason_text = f"cost_check_error: {type(e).__name__}: {str(e)[:100]}"
|
|
177
|
+
return {
|
|
178
|
+
"period": period,
|
|
179
|
+
"ceiling": None,
|
|
180
|
+
"spent": None,
|
|
181
|
+
"exceeded": True,
|
|
182
|
+
"tripped": False,
|
|
183
|
+
"error": str(e),
|
|
184
|
+
"reason": reason_text,
|
|
185
|
+
}
|
|
160
186
|
|
|
161
187
|
|
|
162
188
|
def main(argv=None):
|
|
@@ -172,6 +198,12 @@ def main(argv=None):
|
|
|
172
198
|
|
|
173
199
|
result = check(spent=args.spent, period=args.period)
|
|
174
200
|
|
|
201
|
+
# Check for errors FIRST (fail-closed): exception during check() means abort the wave
|
|
202
|
+
if "error" in result:
|
|
203
|
+
reason = result.get("reason", result["error"])
|
|
204
|
+
print(f"[cost_ceiling] FATAL: {reason}", file=sys.stderr)
|
|
205
|
+
return 1
|
|
206
|
+
|
|
175
207
|
if result["ceiling"] is None:
|
|
176
208
|
print(f"[cost_ceiling] no {args.period} ceiling configured — skipping (spent={result['spent']})")
|
|
177
209
|
return 0
|