@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,209 @@
1
+ """state_store.coordination — lease-by-append claim for exclusive resource access.
2
+
3
+ Implements DB-native CLAIM (lease-via-append) on top of the existing event log:
4
+ to claim resource R, append a claim_requested event to the 'claims' stream;
5
+ the winner is the lowest-version append for that resource key; readers fold
6
+ the stream to see who holds it.
7
+
8
+ Fail-CLOSED by construction: if you cannot append or read, you do NOT hold
9
+ the claim and must not dispatch. TTL + claim_released events handle crashed
10
+ holders (analogous to lock.mjs PID-liveness staleness, but expressed as events).
11
+
12
+ No risky changes to store.py or api.py — uses only existing append/read primitives.
13
+
14
+ Stdlib only: time.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import time
19
+
20
+
21
+ def fold_claims(events: list) -> dict[str, str]:
22
+ """Fold a claims stream into the current state of who holds each resource.
23
+
24
+ Processes claim_requested and claim_released events to determine the winner
25
+ for each resource. The winner is the lowest-version un-released claim for
26
+ that resource (first serialized append wins; ties impossible because versions
27
+ are unique). A claim is valid only if it has NOT been released by a
28
+ subsequent claim_released event. If an instance releases and then re-claims,
29
+ the re-claim is the new active claim for that instance.
30
+
31
+ Args:
32
+ events: list of event dicts from the claims stream
33
+ (as returned by EventStore.read('claims'))
34
+
35
+ Returns:
36
+ dict mapping resource_id -> holding_instance_id for all currently
37
+ held resources. Empty dict if no claims exist or all have been released.
38
+ """
39
+ # Track all claims by resource and instance (as list, to preserve order)
40
+ claims_by_resource = {} # resource -> {instance_id: [versions...]}
41
+ # Track all releases by resource and instance (as sorted list)
42
+ releases_by_resource = {} # resource -> {instance_id: [release_versions]}
43
+
44
+ # First pass: collect all claims and releases
45
+ for ev in events:
46
+ etype = ev.get("type")
47
+ payload = ev.get("payload") or {}
48
+ version = ev.get("version", 0)
49
+
50
+ if etype == "claim_requested":
51
+ resource = payload.get("resource")
52
+ instance_id = payload.get("instance_id")
53
+ if resource is not None and instance_id is not None:
54
+ if resource not in claims_by_resource:
55
+ claims_by_resource[resource] = {}
56
+ if instance_id not in claims_by_resource[resource]:
57
+ claims_by_resource[resource][instance_id] = []
58
+ claims_by_resource[resource][instance_id].append(version)
59
+
60
+ elif etype == "claim_released":
61
+ resource = payload.get("resource")
62
+ instance_id = payload.get("instance_id")
63
+ if resource is not None and instance_id is not None:
64
+ if resource not in releases_by_resource:
65
+ releases_by_resource[resource] = {}
66
+ if instance_id not in releases_by_resource[resource]:
67
+ releases_by_resource[resource][instance_id] = []
68
+ releases_by_resource[resource][instance_id].append(version)
69
+
70
+ # Second pass: determine current holders
71
+ holders = {}
72
+ for resource, claims_dict in claims_by_resource.items():
73
+ # For each instance claiming this resource, find the latest un-released claim
74
+ active_claims = {}
75
+ for instance_id, claim_versions in claims_dict.items():
76
+ releases = sorted(releases_by_resource.get(resource, {}).get(instance_id, []))
77
+
78
+ # Process claims in order, tracking "current active" claim within streaks
79
+ # (separated by releases). The current active claim is the latest claim
80
+ # that comes after the most recent release.
81
+ current_active = None
82
+ for claim_v in sorted(claim_versions):
83
+ # Check if there's a release between the current active and this claim
84
+ if current_active is not None:
85
+ # Check if current_active was released
86
+ if any(r > current_active for r in releases):
87
+ # Yes, released; start a new streak with this claim
88
+ current_active = claim_v
89
+ # else: already in this streak, keep current_active
90
+ else:
91
+ # First claim for this instance
92
+ current_active = claim_v
93
+
94
+ # After processing all claims, check if current_active is released
95
+ if current_active is not None:
96
+ if not any(r > current_active for r in releases):
97
+ # Not released; it's active
98
+ active_claims[instance_id] = current_active
99
+
100
+ # Find the minimum version among active claims (the winner)
101
+ if active_claims:
102
+ winner_instance = min(active_claims.keys(), key=lambda x: active_claims[x])
103
+ holders[resource] = winner_instance
104
+
105
+ return holders
106
+
107
+
108
+ def try_claim(store, resource: str, instance_id: str, ttl: float = 300.0) -> bool:
109
+ """Attempt to claim exclusive access to a resource.
110
+
111
+ Appends a claim_requested event to the 'claims' stream, then re-reads
112
+ and folds to check if this instance won the claim. Fail-CLOSED: if ANY
113
+ exception occurs (append fails, read fails, or exception during fold),
114
+ return False (claim not held, do not proceed).
115
+
116
+ If this instance does NOT win, it retracts its claim by appending a
117
+ claim_released event (scoped to this instance + resource) before returning
118
+ False. This prevents stale-claim resurrection: a losing claim left un-retracted
119
+ in the stream could later become the winner if the true holder releases.
120
+
121
+ Args:
122
+ store: StateAPI or EventStore instance (must have append() and get() methods)
123
+ resource: the resource identifier to claim (e.g., "wave_123", "lane_0", ...)
124
+ instance_id: the instance identifier requesting the claim
125
+ ttl: time-to-live in seconds (default 300s = 5min); embedded in the payload
126
+ for later TTL-based expiry checks (not enforced here, but available
127
+ for reconciliation)
128
+
129
+ Returns:
130
+ bool: True if this instance holds the claim after the append,
131
+ False otherwise (including on any error).
132
+ """
133
+ try:
134
+ # Append claim request to the claims stream
135
+ store.append(
136
+ "claims",
137
+ "claim_requested",
138
+ {"resource": resource, "instance_id": instance_id, "ttl": ttl},
139
+ actor=instance_id,
140
+ )
141
+
142
+ # Re-read claims stream and fold to see current state
143
+ events = store.get("claims")
144
+ claims = fold_claims(events)
145
+
146
+ # Check if we won
147
+ if claims.get(resource) == instance_id:
148
+ return True
149
+
150
+ # We did NOT win: retract our claim to prevent stale-claim resurrection.
151
+ # Fail-closed: if retract fails, still return False (never a false grant).
152
+ try:
153
+ store.append(
154
+ "claims",
155
+ "claim_released",
156
+ {"resource": resource, "instance_id": instance_id},
157
+ actor=instance_id,
158
+ )
159
+ except Exception:
160
+ # Retract failed, but we still don't hold the claim; return False.
161
+ pass
162
+
163
+ return False
164
+ except Exception:
165
+ # Fail-closed: any exception means we don't hold the claim
166
+ return False
167
+
168
+
169
+ def release(store, resource: str, instance_id: str) -> None:
170
+ """Release a claimed resource.
171
+
172
+ Appends a claim_released event to the 'claims' stream, marking that
173
+ this instance no longer holds the resource. Idempotent: releasing
174
+ a resource that was not held is a no-op (the fold will see the
175
+ release but no matching claim, so it has no effect).
176
+
177
+ Args:
178
+ store: StateAPI or EventStore instance (must have append() method)
179
+ resource: the resource identifier being released
180
+ instance_id: the instance identifier releasing the claim
181
+ """
182
+ store.append(
183
+ "claims",
184
+ "claim_released",
185
+ {"resource": resource, "instance_id": instance_id},
186
+ actor=instance_id,
187
+ )
188
+
189
+
190
+ def current_holder(store, resource: str) -> str | None:
191
+ """Return the instance_id currently holding a resource, or None if unclaimed.
192
+
193
+ Reads and folds the claims stream to find the winner for the resource.
194
+ Returns None if the resource is not claimed or has been released.
195
+
196
+ Args:
197
+ store: StateAPI or EventStore instance (must have get() method)
198
+ resource: the resource identifier to query
199
+
200
+ Returns:
201
+ The instance_id of the current holder, or None if unclaimed.
202
+ """
203
+ try:
204
+ events = store.get("claims")
205
+ claims = fold_claims(events)
206
+ return claims.get(resource)
207
+ except Exception:
208
+ # Fail-closed: on any error, we cannot determine the holder
209
+ return None
@@ -0,0 +1,51 @@
1
+ """state_store.identity — stable per-instance orchestrator identity.
2
+
3
+ Derives a stable instance_id for each orchestrator process: hostname:pid:nonce.
4
+ The nonce is a short random string (for entropy) so two orchestrators on the
5
+ same box with different pids still get different identities.
6
+
7
+ Identity is deterministic within a process lifetime (cached) but varies
8
+ across process restarts (the nonce is not stable across restarts by design,
9
+ so instance_id changes on restart). Recommended: auto-derived for zero-config
10
+ solo mode, with optional aesop.config.json override for stable identities
11
+ across restarts.
12
+
13
+ Stdlib only: socket, os, random, string.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import random
19
+ import socket
20
+ import string
21
+
22
+
23
+ # Cached instance_id, computed once per process
24
+ _INSTANCE_ID_CACHE: str | None = None
25
+
26
+
27
+ def get_instance_id() -> str:
28
+ """Return a stable per-instance orchestrator id.
29
+
30
+ Computed once per process and cached. The id is the form:
31
+ hostname:pid:nonce
32
+
33
+ Where nonce is a random 6-character alphanumeric string, used to
34
+ differentiate multiple orchestrators on the same box.
35
+
36
+ Returns:
37
+ A stable string identifier for this orchestrator instance.
38
+ """
39
+ global _INSTANCE_ID_CACHE
40
+ if _INSTANCE_ID_CACHE is None:
41
+ _INSTANCE_ID_CACHE = _derive_instance_id()
42
+ return _INSTANCE_ID_CACHE
43
+
44
+
45
+ def _derive_instance_id() -> str:
46
+ """Derive a new instance_id from hostname, pid, and random nonce."""
47
+ hostname = socket.gethostname()
48
+ pid = os.getpid()
49
+ # Random 6-char alphanumeric nonce for entropy across processes
50
+ nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
51
+ return f"{hostname}:{pid}:{nonce}"
@@ -124,3 +124,66 @@ def save_snapshot(store, stream: str, event_version: int, projection: dict) -> N
124
124
  projection: the materialized state dict (e.g. full tracker projection)
125
125
  """
126
126
  store.save_snapshot(stream, event_version, projection)
127
+
128
+
129
+ AGENT_LIFECYCLE_VERSION = 1
130
+
131
+
132
+ def project_agent_lifecycle(events: list) -> dict:
133
+ """Project current agent lifecycle state from agent_* events.
134
+
135
+ Folds agent lifecycle events into a map of agent_id -> {state, transitions, last_activity}.
136
+
137
+ Event types:
138
+ - agent_dispatched: payload {"agent_id", "timestamp"}; marks dispatch start
139
+ - agent_working: payload {"agent_id", "timestamp"}; marks work in progress
140
+ - agent_done: payload {"agent_id", "timestamp"}; marks completion
141
+ - agent_stalled: payload {"agent_id", "timestamp"}; marks stall/error
142
+
143
+ Returns a dict: {"version": 1, "agents": [{"id", "state", "transitions", "last_activity"}, ...]}
144
+ where transitions is a list of {state, at (ISO 8601 timestamp)} in chronological order.
145
+ """
146
+ agents = {}
147
+
148
+ for ev in events:
149
+ etype = ev.get("type")
150
+ payload = ev.get("payload") or {}
151
+
152
+ if etype in ("agent_dispatched", "agent_working", "agent_done", "agent_stalled"):
153
+ agent_id = payload.get("agent_id")
154
+ timestamp = payload.get("timestamp")
155
+
156
+ if agent_id is None:
157
+ continue
158
+
159
+ # Initialize agent if first time seeing it
160
+ if agent_id not in agents:
161
+ agents[agent_id] = {
162
+ "id": agent_id,
163
+ "state": None,
164
+ "transitions": [],
165
+ "last_activity": None,
166
+ }
167
+
168
+ # Map event type to state
169
+ state_map = {
170
+ "agent_dispatched": "dispatch",
171
+ "agent_working": "working",
172
+ "agent_done": "done",
173
+ "agent_stalled": "stalled",
174
+ }
175
+ new_state = state_map.get(etype)
176
+
177
+ # Only append transition if state actually changed
178
+ if new_state and new_state != agents[agent_id]["state"]:
179
+ agents[agent_id]["state"] = new_state
180
+ if timestamp:
181
+ agents[agent_id]["transitions"].append({
182
+ "state": new_state,
183
+ "at": timestamp,
184
+ })
185
+ agents[agent_id]["last_activity"] = timestamp
186
+
187
+ # Return as versioned list (ordered by agent_id for determinism)
188
+ agent_list = sorted(agents.values(), key=lambda a: a["id"])
189
+ return {"version": AGENT_LIFECYCLE_VERSION, "agents": agent_list}
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ state_store.read_api — Typed read-only facade for state surfaces.
4
+
5
+ Consolidates read patterns for: tracker snapshot, orchestrator-status,
6
+ heartbeat freshness, and ledger rows. This facade allows the underlying
7
+ state representation to change (git → SQLite → Postgres) without altering
8
+ caller code.
9
+
10
+ Callers use:
11
+ api = ReadAPI(state_dir)
12
+ tracker = api.read_tracker_snapshot()
13
+ status = api.read_orchestrator_status()
14
+ is_fresh = api.is_orchestrator_status_fresh(threshold_s=300)
15
+ is_fresh = api.check_heartbeat_fresh(".watchdog-heartbeat", 200)
16
+ rows = api.read_ledger_rows()
17
+ """
18
+ import json
19
+ import sys
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ # Ensure tools module is importable (sys.path fix for bootstrapping)
24
+ repo_root = Path(__file__).parent.parent
25
+ if str(repo_root) not in sys.path:
26
+ sys.path.insert(0, str(repo_root))
27
+
28
+ from tools.common import check_heartbeat_staleness
29
+ from tools.fleet_ledger import parse_ledger_rows as parse_ledger_rows_impl
30
+
31
+
32
+ class ReadAPI:
33
+ """Read-only facade over state surfaces (tracker, orchestrator-status, heartbeats, ledger).
34
+
35
+ Designed to be swappable: backends can change (git → SQLite → Postgres) without
36
+ altering call sites. Current implementation reads from filesystem; future may
37
+ read from SQLite projections or API.
38
+ """
39
+
40
+ def __init__(self, state_dir):
41
+ """Initialize the read API with a state directory.
42
+
43
+ Args:
44
+ state_dir: Path to the state directory (e.g., "state" or "/absolute/path/state")
45
+ """
46
+ self.state_dir = Path(state_dir)
47
+ self.state_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ def read_tracker_snapshot(self):
50
+ """Read the current tracker snapshot.
51
+
52
+ Returns the tracker.json file, or an empty dict if missing/unreadable.
53
+ In the future, this may read from the SQLite projection instead.
54
+
55
+ Returns:
56
+ dict: Tracker snapshot (items, metadata, etc.) or {} if unavailable.
57
+ """
58
+ tracker_file = self.state_dir / "tracker.json"
59
+ if not tracker_file.exists():
60
+ return {}
61
+
62
+ try:
63
+ content = tracker_file.read_text(encoding="utf-8")
64
+ return json.loads(content)
65
+ except Exception:
66
+ # Malformed JSON or read error: return empty dict (fail-open)
67
+ return {}
68
+
69
+ def read_orchestrator_status(self):
70
+ """Read orchestrator-status.json if present.
71
+
72
+ Returns:
73
+ dict with "updated_at" (required) and optional "phase", "activity" fields,
74
+ or None if file missing/unreadable.
75
+ """
76
+ status_file = self.state_dir / "orchestrator-status.json"
77
+ if not status_file.exists():
78
+ return None
79
+
80
+ try:
81
+ content = status_file.read_text(encoding="utf-8")
82
+ data = json.loads(content)
83
+ # Only require updated_at field for freshness checks;
84
+ # phase is optional (may not be set when status.json created before phase exists)
85
+ # Don't validate here—let callers decide what fields are required for their use case
86
+ return data
87
+ except Exception:
88
+ return None
89
+
90
+ def is_orchestrator_status_fresh(self, threshold_s=300):
91
+ """Check if orchestrator-status.json exists and is fresh.
92
+
93
+ Reads updated_at timestamp and compares to current time.
94
+ Returns False if file missing, unparseable, or stale.
95
+
96
+ Args:
97
+ threshold_s: Staleness threshold in seconds (default: 300s)
98
+
99
+ Returns:
100
+ bool: True if file is fresh, False if stale/missing/malformed.
101
+ """
102
+ status = self.read_orchestrator_status()
103
+ if status is None:
104
+ return False
105
+
106
+ try:
107
+ updated_at_str = status.get("updated_at")
108
+ if not updated_at_str:
109
+ return False
110
+
111
+ # Parse ISO format timestamp (handle both "Z" and "+00:00")
112
+ normalized = updated_at_str.replace("Z", "+00:00")
113
+ updated_at = datetime.fromisoformat(normalized)
114
+
115
+ now = datetime.now(timezone.utc)
116
+ age = (now - updated_at).total_seconds()
117
+
118
+ # Treat future-dated timestamps as stale (fail-closed)
119
+ if age < -120: # More than 2min in future = clock skew, treat as stale
120
+ return False
121
+
122
+ # Clamp small negative ages to 0
123
+ age = max(0, age)
124
+
125
+ return age < threshold_s
126
+ except Exception:
127
+ return False
128
+
129
+ def check_heartbeat_fresh(self, heartbeat_filename, threshold_s=200):
130
+ """Check if a heartbeat file is fresh.
131
+
132
+ Delegates to tools.common.check_heartbeat_staleness (single source of truth).
133
+
134
+ Args:
135
+ heartbeat_filename: Filename in state_dir (e.g., ".watchdog-heartbeat")
136
+ threshold_s: Staleness threshold in seconds (default: 200s)
137
+
138
+ Returns:
139
+ bool: True if file exists and is fresh, False otherwise.
140
+ """
141
+ hb_file = self.state_dir / heartbeat_filename
142
+ # Use the shared implementation from common.py; invert staleness to freshness
143
+ is_stale, _, _ = check_heartbeat_staleness(hb_file, threshold_s)
144
+ return not is_stale
145
+
146
+ def read_ledger_rows(self):
147
+ """Read OUTCOMES-LEDGER.md and parse rows.
148
+
149
+ Delegates to tools.fleet_ledger.parse_ledger_rows() (single source of truth).
150
+ Returns empty list if file missing or unparseable.
151
+
152
+ Returns:
153
+ list: List of row dicts parsed from the ledger table, or [].
154
+ """
155
+ # Use the shared implementation from fleet_ledger.py
156
+ return parse_ledger_rows_impl()