@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,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
- if not hb_file.exists():
45
- return True, 0, "Heartbeat file missing"
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
- age_seconds = max(0, age_seconds) # Never negative
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)"