@matt82198/aesop 0.1.0-beta.2 → 0.1.0-beta.4
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 +37 -94
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +89 -264
- package/aesop.config.example.json +27 -26
- package/bin/CLAUDE.md +39 -0
- package/bin/cli.js +368 -24
- package/daemons/CLAUDE.md +33 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +90 -10
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/spikes/tiered-cognition/README.md +29 -0
- package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/hooks/CLAUDE.md +38 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +29 -8
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +529 -62
- package/package.json +12 -3
- package/skills/CLAUDE.md +3 -0
- package/skills/power/SKILL.md +161 -0
- package/tools/CLAUDE.md +191 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +204 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +131 -31
- package/tools/stall_check.py +172 -0
- package/tools/verify_dash.py +694 -0
- package/tools/verify_submit_encoding.py +194 -0
- package/ui/CLAUDE.md +109 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +179 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +58 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +586 -0
- package/ui/config.py +114 -0
- package/ui/csrf.py +140 -0
- package/ui/handler.py +426 -0
- package/ui/render.py +29 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +168 -0
- package/ui/templates/dashboard.html +1202 -0
package/ui/collectors.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Aesop UI — read-only data collectors + tracker CRUD + SSE section snapshots (wave-9 split)."""
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from time import time
|
|
11
|
+
|
|
12
|
+
import config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_audit_backlog():
|
|
16
|
+
"""
|
|
17
|
+
Parse AUDIT-BACKLOG.md and return structured tier data.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
dict with 'tiers' list, each tier containing:
|
|
21
|
+
{
|
|
22
|
+
"tier": "P0" | "P1" | "P2" | "Needs decision",
|
|
23
|
+
"items": [
|
|
24
|
+
{"status": "✅"|"🔵"|"⬜"|"⏸", "tag": "[sec]", "title": "..."},
|
|
25
|
+
...
|
|
26
|
+
],
|
|
27
|
+
"done": int,
|
|
28
|
+
"inflight": int,
|
|
29
|
+
"todo": int,
|
|
30
|
+
"total": int
|
|
31
|
+
}
|
|
32
|
+
"""
|
|
33
|
+
result = {"tiers": []}
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
if not config.AUDIT_BACKLOG_FILE.exists():
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
content = config.AUDIT_BACKLOG_FILE.read_text(encoding='utf-8')
|
|
40
|
+
except Exception as e:
|
|
41
|
+
print(f"[collectors] Failed to read audit backlog: {e}", file=sys.stderr)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
# Split into lines
|
|
45
|
+
lines = content.split('\n')
|
|
46
|
+
|
|
47
|
+
# Parse sections and items.
|
|
48
|
+
#
|
|
49
|
+
# NOTE: tier headers are matched by REGEX PREFIX (e.g. "## P0\b"), not by exact/startswith
|
|
50
|
+
# comparison against a fixed full title string. The backlog file's section titles evolve
|
|
51
|
+
# over time (suffixes like "(do first)" become "(wave 5, from five-lens re-audit)"), and a
|
|
52
|
+
# hardcoded full-string tier_map silently stops matching anything when that happens — the
|
|
53
|
+
# panel then renders "no backlog found" forever even though the file is full of live items.
|
|
54
|
+
# Regex-on-prefix survives any suffix/rename of the tier header.
|
|
55
|
+
current_tier = None
|
|
56
|
+
tier_patterns = [
|
|
57
|
+
(re.compile(r'^##\s*P0\b'), "P0"),
|
|
58
|
+
(re.compile(r'^##\s*P1\b'), "P1"),
|
|
59
|
+
(re.compile(r'^##\s*P2\b'), "P2"),
|
|
60
|
+
(re.compile(r'^##\s*Needs a user decision\b', re.IGNORECASE), "Needs decision"),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
# Stop parsing at these sections
|
|
64
|
+
stop_sections = ["## Landing log", "## Dispatch plan"]
|
|
65
|
+
|
|
66
|
+
tiers_data = {} # tier_name -> list of items
|
|
67
|
+
|
|
68
|
+
for line in lines:
|
|
69
|
+
line_stripped = line.strip()
|
|
70
|
+
|
|
71
|
+
# Check if we hit a stop section
|
|
72
|
+
if any(line_stripped.startswith(stop) for stop in stop_sections):
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
# Any level-2 header re-evaluates current_tier. This is deliberate: a header that
|
|
76
|
+
# doesn't match a known tier (e.g. "## Features (user-requested)") resets current_tier
|
|
77
|
+
# to None, so its items are NOT silently attributed to whatever tier came before it
|
|
78
|
+
# (bleed-through bug from sticky state).
|
|
79
|
+
if line_stripped.startswith("## "):
|
|
80
|
+
matched_tier = None
|
|
81
|
+
for pattern, tier_name in tier_patterns:
|
|
82
|
+
if pattern.match(line_stripped):
|
|
83
|
+
matched_tier = tier_name
|
|
84
|
+
break
|
|
85
|
+
current_tier = matched_tier
|
|
86
|
+
if current_tier and current_tier not in tiers_data:
|
|
87
|
+
tiers_data[current_tier] = []
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
# Parse item line (starts with "- " and a status glyph)
|
|
91
|
+
if current_tier and line_stripped.startswith("- "):
|
|
92
|
+
# Status glyphs: ✅ 🔵 ⬜ ⏸
|
|
93
|
+
status = None
|
|
94
|
+
rest = line_stripped[2:].strip() # Remove "- "
|
|
95
|
+
|
|
96
|
+
if rest.startswith("✅"):
|
|
97
|
+
status = "✅"
|
|
98
|
+
rest = rest[1:].strip()
|
|
99
|
+
elif rest.startswith("🔵"):
|
|
100
|
+
status = "🔵"
|
|
101
|
+
rest = rest[1:].strip()
|
|
102
|
+
elif rest.startswith("⬜"):
|
|
103
|
+
status = "⬜"
|
|
104
|
+
rest = rest[1:].strip()
|
|
105
|
+
elif rest.startswith("⏸"):
|
|
106
|
+
status = "⏸"
|
|
107
|
+
rest = rest[1:].strip()
|
|
108
|
+
|
|
109
|
+
if status:
|
|
110
|
+
# Extract tag and title from "**[tag] Title...**"
|
|
111
|
+
# Pattern: **[something] rest**
|
|
112
|
+
if rest.startswith("**"):
|
|
113
|
+
# Find the closing **
|
|
114
|
+
match = re.match(r'\*\*\[([^\]]+)\]\s+(.+?)\*\*', rest)
|
|
115
|
+
if match:
|
|
116
|
+
tag = f"[{match.group(1)}]"
|
|
117
|
+
title = match.group(2)
|
|
118
|
+
|
|
119
|
+
tiers_data[current_tier].append({
|
|
120
|
+
"status": status,
|
|
121
|
+
"tag": tag,
|
|
122
|
+
"title": title
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
# Convert to result format with counts
|
|
126
|
+
tier_order = ["P0", "P1", "P2", "Needs decision"]
|
|
127
|
+
for tier_name in tier_order:
|
|
128
|
+
if tier_name in tiers_data:
|
|
129
|
+
items = tiers_data[tier_name]
|
|
130
|
+
done = sum(1 for item in items if item["status"] == "✅")
|
|
131
|
+
inflight = sum(1 for item in items if item["status"] == "🔵")
|
|
132
|
+
todo = sum(1 for item in items if item["status"] == "⬜")
|
|
133
|
+
|
|
134
|
+
result["tiers"].append({
|
|
135
|
+
"tier": tier_name,
|
|
136
|
+
"items": items,
|
|
137
|
+
"done": done,
|
|
138
|
+
"inflight": inflight,
|
|
139
|
+
"todo": todo,
|
|
140
|
+
"total": len(items)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
def get_heartbeat_status():
|
|
146
|
+
"""Read daemon heartbeat age and status.
|
|
147
|
+
|
|
148
|
+
Buckets age to prevent every-tick hash change: age is reported in 3-second buckets
|
|
149
|
+
(e.g., 0-2s → 0, 3-5s → 3, 6-8s → 6, ...) so the heartbeat snapshot only changes
|
|
150
|
+
every ~3 seconds, not every 1 second. This preserves the change-hash gate effectiveness.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
if not config.WATCHDOG_HEARTBEAT.exists():
|
|
154
|
+
return {"alive": "UNKNOWN", "age": -1, "threshold": 300}
|
|
155
|
+
content = config.WATCHDOG_HEARTBEAT.read_text(encoding='utf-8').strip()
|
|
156
|
+
if not content:
|
|
157
|
+
return {"alive": "UNKNOWN", "age": -1, "threshold": 300}
|
|
158
|
+
# Parse epoch value robustly; assume seconds (standard epoch format)
|
|
159
|
+
try:
|
|
160
|
+
timestamp = int(content)
|
|
161
|
+
except ValueError:
|
|
162
|
+
# Retry once in case of race during daemon write
|
|
163
|
+
try:
|
|
164
|
+
content = config.WATCHDOG_HEARTBEAT.read_text().strip()
|
|
165
|
+
timestamp = int(content)
|
|
166
|
+
except Exception as e:
|
|
167
|
+
print(f"[collectors] Failed to parse watchdog heartbeat: {e}", file=sys.stderr)
|
|
168
|
+
return {"alive": "unknown", "age": -1, "threshold": 300}
|
|
169
|
+
# Age in seconds: now_seconds - heartbeat_seconds
|
|
170
|
+
age_seconds = int(time()) - timestamp
|
|
171
|
+
# Bucket age to 3-second intervals to prevent hash churn
|
|
172
|
+
age_bucketed = (age_seconds // 3) * 3
|
|
173
|
+
alive = "ALIVE" if age_seconds < 300 else "STALE"
|
|
174
|
+
return {"alive": alive, "age": age_bucketed, "threshold": 300}
|
|
175
|
+
except Exception as e:
|
|
176
|
+
print(f"[collectors] Failed to get watchdog heartbeat: {e}", file=sys.stderr)
|
|
177
|
+
return {"alive": "unknown", "age": -1, "threshold": 300}
|
|
178
|
+
|
|
179
|
+
def get_monitor_heartbeat_status():
|
|
180
|
+
"""Read orchestration monitor heartbeat age and status.
|
|
181
|
+
|
|
182
|
+
Buckets age to prevent every-tick hash change: age is reported in 3-second buckets
|
|
183
|
+
(e.g., 0-2s → 0, 3-5s → 3, 6-8s → 6, ...) so the monitor snapshot only changes
|
|
184
|
+
every ~3 seconds, not every 1 second. This preserves the change-hash gate effectiveness.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
# Check both possible paths: state/.monitor-heartbeat and monitor/.monitor-heartbeat
|
|
188
|
+
monitor_hb = config.MONITOR_HEARTBEAT
|
|
189
|
+
if not monitor_hb.exists():
|
|
190
|
+
# Try alternate path
|
|
191
|
+
alt_path = config.AESOP_ROOT / "monitor" / ".monitor-heartbeat"
|
|
192
|
+
if not alt_path.exists():
|
|
193
|
+
return {"alive": "not running", "age": -1, "threshold": 3600}
|
|
194
|
+
monitor_hb = alt_path
|
|
195
|
+
|
|
196
|
+
content = monitor_hb.read_text(encoding='utf-8').strip()
|
|
197
|
+
if not content:
|
|
198
|
+
return {"alive": "not running", "age": -1, "threshold": 3600}
|
|
199
|
+
# Parse epoch value robustly; assume seconds (standard epoch format)
|
|
200
|
+
try:
|
|
201
|
+
timestamp = int(content)
|
|
202
|
+
except ValueError:
|
|
203
|
+
# Retry once in case of race during monitor write
|
|
204
|
+
try:
|
|
205
|
+
content = monitor_hb.read_text().strip()
|
|
206
|
+
timestamp = int(content)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
print(f"[collectors] Failed to parse monitor heartbeat: {e}", file=sys.stderr)
|
|
209
|
+
return {"alive": "unknown", "age": -1, "threshold": 3600}
|
|
210
|
+
# Age in seconds: now_seconds - heartbeat_seconds
|
|
211
|
+
age_seconds = int(time()) - timestamp
|
|
212
|
+
# Bucket age to 3-second intervals to prevent hash churn
|
|
213
|
+
age_bucketed = (age_seconds // 3) * 3
|
|
214
|
+
alive = "ALIVE" if age_seconds < 3600 else "STALE"
|
|
215
|
+
return {"alive": alive, "age": age_bucketed, "threshold": 3600}
|
|
216
|
+
except Exception as e:
|
|
217
|
+
print(f"[collectors] Failed to get monitor heartbeat: {e}", file=sys.stderr)
|
|
218
|
+
return {"alive": "unknown", "age": -1, "threshold": 3600}
|
|
219
|
+
|
|
220
|
+
def get_main_thread_messages():
|
|
221
|
+
"""Read last ~12 messages from newest session JSONL."""
|
|
222
|
+
messages = []
|
|
223
|
+
try:
|
|
224
|
+
if not config.TRANSCRIPTS_ROOT.exists():
|
|
225
|
+
return messages
|
|
226
|
+
# Find newest .jsonl
|
|
227
|
+
jsonl_files = sorted(
|
|
228
|
+
config.TRANSCRIPTS_ROOT.glob("**/*.jsonl"),
|
|
229
|
+
key=lambda p: p.stat().st_mtime,
|
|
230
|
+
reverse=True
|
|
231
|
+
)
|
|
232
|
+
if not jsonl_files:
|
|
233
|
+
return messages
|
|
234
|
+
|
|
235
|
+
newest = jsonl_files[0]
|
|
236
|
+
with open(newest, 'r', encoding='utf-8', errors='ignore') as f:
|
|
237
|
+
lines = f.readlines()
|
|
238
|
+
# Get last 30 lines to extract ~12 message turns
|
|
239
|
+
for line in lines[-30:]:
|
|
240
|
+
try:
|
|
241
|
+
obj = json.loads(line)
|
|
242
|
+
role = obj.get("role", "unknown")
|
|
243
|
+
if role in ("user", "assistant"):
|
|
244
|
+
# Extract text content
|
|
245
|
+
content = obj.get("content", [])
|
|
246
|
+
text = ""
|
|
247
|
+
if isinstance(content, list):
|
|
248
|
+
for block in content:
|
|
249
|
+
if isinstance(block, dict) and "text" in block:
|
|
250
|
+
text = block["text"]
|
|
251
|
+
break
|
|
252
|
+
elif isinstance(content, str):
|
|
253
|
+
text = content
|
|
254
|
+
|
|
255
|
+
if text:
|
|
256
|
+
# Truncate to 200 chars and sanitize
|
|
257
|
+
preview = text[:200].replace("\n", " ").strip()
|
|
258
|
+
timestamp = obj.get("timestamp", "")
|
|
259
|
+
messages.append({
|
|
260
|
+
"role": role,
|
|
261
|
+
"text": preview,
|
|
262
|
+
"timestamp": timestamp
|
|
263
|
+
})
|
|
264
|
+
except (json.JSONDecodeError, KeyError):
|
|
265
|
+
pass
|
|
266
|
+
# Keep only last 12
|
|
267
|
+
messages = messages[-12:]
|
|
268
|
+
except Exception as e:
|
|
269
|
+
print(f"[collectors] Failed to read main thread messages: {e}", file=sys.stderr)
|
|
270
|
+
return messages
|
|
271
|
+
|
|
272
|
+
def get_repos_status():
|
|
273
|
+
"""Read repos from .watchdog-repos.json."""
|
|
274
|
+
repos = []
|
|
275
|
+
try:
|
|
276
|
+
if not config.REPOS_JSON.exists():
|
|
277
|
+
return repos
|
|
278
|
+
data = json.loads(config.REPOS_JSON.read_text(encoding='utf-8'))
|
|
279
|
+
if isinstance(data, list):
|
|
280
|
+
repos = data[:10] # Limit to 10
|
|
281
|
+
elif isinstance(data, dict):
|
|
282
|
+
repos = [{"repo": k, "state": v} for k, v in data.items()][:10]
|
|
283
|
+
except Exception as e:
|
|
284
|
+
print(f"[collectors] Failed to read repos status: {e}", file=sys.stderr)
|
|
285
|
+
return repos
|
|
286
|
+
|
|
287
|
+
def get_recent_events():
|
|
288
|
+
"""Read last 8 lines from FLEET-BACKUP.log."""
|
|
289
|
+
events = []
|
|
290
|
+
try:
|
|
291
|
+
if not config.BACKUP_LOG.exists():
|
|
292
|
+
return events
|
|
293
|
+
lines = config.BACKUP_LOG.read_text(encoding='utf-8').strip().split('\n')
|
|
294
|
+
events = [line.strip() for line in lines[-8:] if line.strip()]
|
|
295
|
+
except Exception as e:
|
|
296
|
+
print(f"[collectors] Failed to read recent events: {e}", file=sys.stderr)
|
|
297
|
+
return events
|
|
298
|
+
|
|
299
|
+
def get_alerts():
|
|
300
|
+
"""Read SECURITY-ALERTS.log, skip NOTE:/RESOLVED-FP, count by severity."""
|
|
301
|
+
alerts = {"count": 0, "lines": []}
|
|
302
|
+
try:
|
|
303
|
+
if not config.ALERTS_LOG.exists():
|
|
304
|
+
return alerts
|
|
305
|
+
lines = config.ALERTS_LOG.read_text(encoding='utf-8').strip().split('\n')
|
|
306
|
+
unreviewed = [
|
|
307
|
+
line.strip() for line in lines
|
|
308
|
+
if line.strip()
|
|
309
|
+
and "NOTE:" not in line
|
|
310
|
+
and "RESOLVED-FP" not in line
|
|
311
|
+
]
|
|
312
|
+
alerts["count"] = len(unreviewed)
|
|
313
|
+
alerts["lines"] = unreviewed[-5:] # Show last 5
|
|
314
|
+
except Exception as e:
|
|
315
|
+
print(f"[collectors] Failed to read alerts: {e}", file=sys.stderr)
|
|
316
|
+
return alerts
|
|
317
|
+
|
|
318
|
+
def load_tracker():
|
|
319
|
+
"""Load tracker.json, return empty tracker if missing or corrupt."""
|
|
320
|
+
if not config.TRACKER_FILE.exists():
|
|
321
|
+
return {"version": 1, "items": []}
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
data = json.loads(config.TRACKER_FILE.read_text(encoding='utf-8'))
|
|
325
|
+
if not isinstance(data, dict) or "version" not in data:
|
|
326
|
+
raise ValueError("Invalid tracker schema")
|
|
327
|
+
return data
|
|
328
|
+
except Exception as e:
|
|
329
|
+
print(f"[tracker] Corrupt tracker.json: {e}", file=sys.stderr)
|
|
330
|
+
corrupt_path = config.TRACKER_FILE.with_suffix('.json.corrupt')
|
|
331
|
+
try:
|
|
332
|
+
if config.TRACKER_FILE.exists():
|
|
333
|
+
config.TRACKER_FILE.rename(corrupt_path)
|
|
334
|
+
except Exception as e:
|
|
335
|
+
print(f"[tracker] Failed to rename corrupt tracker: {e}", file=sys.stderr)
|
|
336
|
+
return {"version": 1, "items": []}
|
|
337
|
+
|
|
338
|
+
def save_tracker(tracker):
|
|
339
|
+
"""Save tracker atomically using temp file + os.replace."""
|
|
340
|
+
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
temp_file = config.TRACKER_FILE.with_suffix('.json.tmp')
|
|
342
|
+
try:
|
|
343
|
+
with open(temp_file, 'w', encoding='utf-8') as f:
|
|
344
|
+
json.dump(tracker, f, indent=2)
|
|
345
|
+
os.replace(str(temp_file), str(config.TRACKER_FILE))
|
|
346
|
+
except Exception as e:
|
|
347
|
+
print(f"[tracker] Error saving tracker: {e}", file=sys.stderr)
|
|
348
|
+
try:
|
|
349
|
+
temp_file.unlink()
|
|
350
|
+
except Exception as ue:
|
|
351
|
+
print(f"[tracker] Failed to unlink temp file: {ue}", file=sys.stderr)
|
|
352
|
+
raise
|
|
353
|
+
|
|
354
|
+
def migrate_tracker_from_backlog():
|
|
355
|
+
"""One-time idempotent migration: AUDIT-BACKLOG.md -> tracker.json."""
|
|
356
|
+
if config.TRACKER_FILE.exists():
|
|
357
|
+
return load_tracker()
|
|
358
|
+
|
|
359
|
+
backlog_data = parse_audit_backlog()
|
|
360
|
+
if not backlog_data.get("tiers"):
|
|
361
|
+
return {"version": 1, "items": []}
|
|
362
|
+
|
|
363
|
+
items = []
|
|
364
|
+
for tier_data in backlog_data["tiers"]:
|
|
365
|
+
priority = tier_data["tier"]
|
|
366
|
+
|
|
367
|
+
for backlog_item in tier_data.get("items", []):
|
|
368
|
+
status_glyph = backlog_item["status"]
|
|
369
|
+
|
|
370
|
+
if status_glyph == "✅":
|
|
371
|
+
status, lane = "done", "done"
|
|
372
|
+
tags = []
|
|
373
|
+
elif status_glyph == "🔵":
|
|
374
|
+
status, lane = "in-progress", "in-progress"
|
|
375
|
+
tags = []
|
|
376
|
+
elif status_glyph == "⏸":
|
|
377
|
+
status, lane = "todo", "proposed"
|
|
378
|
+
tags = ["needs-decision"]
|
|
379
|
+
else:
|
|
380
|
+
status, lane = "todo", "ranked"
|
|
381
|
+
tags = []
|
|
382
|
+
|
|
383
|
+
title = backlog_item.get("title", "")
|
|
384
|
+
tag_prefix = backlog_item.get("tag", "")
|
|
385
|
+
if tag_prefix:
|
|
386
|
+
tag_value = tag_prefix.strip("[]")
|
|
387
|
+
if tag_value and tag_value not in tags:
|
|
388
|
+
tags.insert(0, tag_value)
|
|
389
|
+
|
|
390
|
+
item = {
|
|
391
|
+
"id": secrets.token_hex(6),
|
|
392
|
+
"title": title,
|
|
393
|
+
"priority": priority,
|
|
394
|
+
"status": status,
|
|
395
|
+
"lane": lane,
|
|
396
|
+
"source": "audit-backlog-migration",
|
|
397
|
+
"tags": tags,
|
|
398
|
+
"notes": None,
|
|
399
|
+
"pr_link": None,
|
|
400
|
+
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
401
|
+
"completed_at": None
|
|
402
|
+
}
|
|
403
|
+
items.append(item)
|
|
404
|
+
|
|
405
|
+
tracker = {"version": 1, "items": items}
|
|
406
|
+
save_tracker(tracker)
|
|
407
|
+
return tracker
|
|
408
|
+
|
|
409
|
+
def get_tracker_items(status=None, priority=None):
|
|
410
|
+
"""Retrieve tracker items with optional filters."""
|
|
411
|
+
tracker = load_tracker()
|
|
412
|
+
items = tracker.get("items", [])
|
|
413
|
+
|
|
414
|
+
if status:
|
|
415
|
+
items = [i for i in items if i.get("status") == status]
|
|
416
|
+
if priority:
|
|
417
|
+
items = [i for i in items if i.get("priority") == priority]
|
|
418
|
+
|
|
419
|
+
return items
|
|
420
|
+
|
|
421
|
+
def create_tracker_item(data):
|
|
422
|
+
"""Create a new tracker item."""
|
|
423
|
+
tracker = load_tracker()
|
|
424
|
+
|
|
425
|
+
item = {
|
|
426
|
+
"id": secrets.token_hex(6),
|
|
427
|
+
"title": data.get("title", ""),
|
|
428
|
+
"priority": data.get("priority", "P1"),
|
|
429
|
+
"status": data.get("status", "todo"),
|
|
430
|
+
"lane": data.get("lane", "proposed"),
|
|
431
|
+
"source": data.get("source", "manual"),
|
|
432
|
+
"tags": data.get("tags", []) if isinstance(data.get("tags"), list) else [],
|
|
433
|
+
"notes": data.get("notes"),
|
|
434
|
+
"pr_link": data.get("pr_link"),
|
|
435
|
+
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
436
|
+
"completed_at": None
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
tracker["items"].append(item)
|
|
440
|
+
save_tracker(tracker)
|
|
441
|
+
return item
|
|
442
|
+
|
|
443
|
+
def update_tracker_item(item_id, update_data):
|
|
444
|
+
"""Update a tracker item by id."""
|
|
445
|
+
tracker = load_tracker()
|
|
446
|
+
|
|
447
|
+
item = next((i for i in tracker["items"] if i["id"] == item_id), None)
|
|
448
|
+
if not item:
|
|
449
|
+
raise Exception(f"404 Item not found: {item_id}")
|
|
450
|
+
|
|
451
|
+
for key in ["status", "lane", "priority", "notes", "pr_link", "tags"]:
|
|
452
|
+
if key in update_data:
|
|
453
|
+
item[key] = update_data[key]
|
|
454
|
+
|
|
455
|
+
if update_data.get("status") == "done" and not item.get("completed_at"):
|
|
456
|
+
item["completed_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
457
|
+
|
|
458
|
+
save_tracker(tracker)
|
|
459
|
+
return item
|
|
460
|
+
|
|
461
|
+
def delete_tracker_item(item_id):
|
|
462
|
+
"""Soft-delete a tracker item (mark as archived)."""
|
|
463
|
+
tracker = load_tracker()
|
|
464
|
+
|
|
465
|
+
item = next((i for i in tracker["items"] if i["id"] == item_id), None)
|
|
466
|
+
if not item:
|
|
467
|
+
raise Exception(f"404 Item not found: {item_id}")
|
|
468
|
+
|
|
469
|
+
item["status"] = "archived"
|
|
470
|
+
save_tracker(tracker)
|
|
471
|
+
return item
|
|
472
|
+
|
|
473
|
+
def _snapshot_data():
|
|
474
|
+
"""Everything the 'data' SSE section covers (header, repos, events, alerts, messages)."""
|
|
475
|
+
return {
|
|
476
|
+
"watchdog": get_heartbeat_status(),
|
|
477
|
+
"monitor": get_monitor_heartbeat_status(),
|
|
478
|
+
"repos": get_repos_status(),
|
|
479
|
+
"events": get_recent_events(),
|
|
480
|
+
"alerts": get_alerts(),
|
|
481
|
+
"messages": get_main_thread_messages(),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
def _snapshot_tracker():
|
|
485
|
+
"""Read tracker.json, return {items: [...]}."""
|
|
486
|
+
tracker_file = config.STATE_DIR / "tracker.json"
|
|
487
|
+
if not tracker_file.exists():
|
|
488
|
+
return {"items": []}
|
|
489
|
+
try:
|
|
490
|
+
data = json.loads(tracker_file.read_text(encoding='utf-8'))
|
|
491
|
+
if isinstance(data, dict) and "items" in data:
|
|
492
|
+
return {"items": data.get("items", [])}
|
|
493
|
+
return {"items": []}
|
|
494
|
+
except Exception as e:
|
|
495
|
+
print(f"[tracker] Snapshot error: {e}", file=sys.stderr)
|
|
496
|
+
return {"items": []}
|
|
497
|
+
|
|
498
|
+
def _snapshot_orchestrator_status():
|
|
499
|
+
"""Read and normalize orchestrator-status.json."""
|
|
500
|
+
status_file = config.STATE_DIR / "orchestrator-status.json"
|
|
501
|
+
if not status_file.exists():
|
|
502
|
+
return {"orchestrators": []}
|
|
503
|
+
try:
|
|
504
|
+
data = json.loads(status_file.read_text(encoding='utf-8'))
|
|
505
|
+
if not isinstance(data, dict):
|
|
506
|
+
return {"orchestrators": []}
|
|
507
|
+
# Already normalized list shape
|
|
508
|
+
if "orchestrators" in data and isinstance(data["orchestrators"], list):
|
|
509
|
+
return data
|
|
510
|
+
# Wrap bare object as single entry
|
|
511
|
+
if "id" in data or "role" in data:
|
|
512
|
+
age_seconds = 0
|
|
513
|
+
stale = False
|
|
514
|
+
try:
|
|
515
|
+
updated_at_str = data.get("updated_at", "")
|
|
516
|
+
if updated_at_str:
|
|
517
|
+
updated_at_str = updated_at_str.rstrip('Z')
|
|
518
|
+
updated_at = datetime.fromisoformat(updated_at_str)
|
|
519
|
+
age_seconds = int((datetime.now(timezone.utc).replace(tzinfo=None) - updated_at).total_seconds())
|
|
520
|
+
stale = age_seconds > 1800
|
|
521
|
+
except Exception as e:
|
|
522
|
+
print(f"[collectors] Failed to parse orchestrator timestamp: {e}", file=sys.stderr)
|
|
523
|
+
entry = dict(data)
|
|
524
|
+
entry["age_seconds"] = age_seconds
|
|
525
|
+
entry["stale"] = stale
|
|
526
|
+
return {"orchestrators": [entry]}
|
|
527
|
+
return {"orchestrators": []}
|
|
528
|
+
except Exception as e:
|
|
529
|
+
print(f"[status] Snapshot error: {e}", file=sys.stderr)
|
|
530
|
+
return {"orchestrators": []}
|
|
531
|
+
|
|
532
|
+
def drain_tracker_inbox():
|
|
533
|
+
"""Drain .tracker-inbox.jsonl, create items idempotently."""
|
|
534
|
+
inbox_file = config.STATE_DIR / ".tracker-inbox.jsonl"
|
|
535
|
+
if not inbox_file.exists():
|
|
536
|
+
return []
|
|
537
|
+
|
|
538
|
+
created = []
|
|
539
|
+
try:
|
|
540
|
+
content = inbox_file.read_text(encoding='utf-8')
|
|
541
|
+
if not content.strip():
|
|
542
|
+
inbox_file.unlink()
|
|
543
|
+
return []
|
|
544
|
+
|
|
545
|
+
lines = content.strip().splitlines()
|
|
546
|
+
tracker = load_tracker()
|
|
547
|
+
existing_hashes = set()
|
|
548
|
+
for item in tracker.get("items", []):
|
|
549
|
+
source = item.get("source", "")
|
|
550
|
+
title = item.get("title", "")
|
|
551
|
+
h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
|
|
552
|
+
existing_hashes.add(h)
|
|
553
|
+
|
|
554
|
+
rejects = []
|
|
555
|
+
for line in lines:
|
|
556
|
+
line = line.strip()
|
|
557
|
+
if not line:
|
|
558
|
+
continue
|
|
559
|
+
try:
|
|
560
|
+
entry = json.loads(line)
|
|
561
|
+
if not isinstance(entry, dict):
|
|
562
|
+
rejects.append(line)
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
source = entry.get("source", "")
|
|
566
|
+
title = entry.get("title", "")
|
|
567
|
+
h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
|
|
568
|
+
|
|
569
|
+
if h not in existing_hashes:
|
|
570
|
+
item = create_tracker_item(entry)
|
|
571
|
+
created.append(item)
|
|
572
|
+
existing_hashes.add(h)
|
|
573
|
+
except json.JSONDecodeError:
|
|
574
|
+
rejects.append(line)
|
|
575
|
+
except Exception as e:
|
|
576
|
+
rejects.append(line + " # " + str(e))
|
|
577
|
+
|
|
578
|
+
if rejects:
|
|
579
|
+
rejects_file = inbox_file.with_name(".tracker-inbox.rejects")
|
|
580
|
+
rejects_file.write_text("\n".join(rejects) + "\n", encoding='utf-8')
|
|
581
|
+
|
|
582
|
+
inbox_file.unlink()
|
|
583
|
+
except Exception as e:
|
|
584
|
+
print(f"[inbox] Drain error: {e}", file=sys.stderr)
|
|
585
|
+
|
|
586
|
+
return created
|