@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +37 -94
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +89 -264
  4. package/aesop.config.example.json +27 -26
  5. package/bin/CLAUDE.md +39 -0
  6. package/bin/cli.js +368 -24
  7. package/daemons/CLAUDE.md +33 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +90 -10
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELIABILITY.md +61 -0
  23. package/docs/RESTORE.md +397 -0
  24. package/docs/SCRIPTS-POLICY.md +97 -0
  25. package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
  26. package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
  27. package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
  28. package/docs/spikes/tiered-cognition/README.md +29 -0
  29. package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  30. package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  31. package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  32. package/hooks/CLAUDE.md +38 -0
  33. package/hooks/claude/force-model-policy.mjs +159 -0
  34. package/hooks/pre-push-policy.sh +753 -0
  35. package/monitor/.signal-state.json +3 -0
  36. package/monitor/ACTIONS.log +1 -0
  37. package/monitor/BRIEF.md +24 -0
  38. package/monitor/CHARTER.md +29 -8
  39. package/monitor/CLAUDE.md +40 -0
  40. package/monitor/SIGNALS.json +54 -0
  41. package/monitor/collect-signals.mjs +529 -62
  42. package/package.json +12 -3
  43. package/skills/CLAUDE.md +3 -0
  44. package/skills/power/SKILL.md +161 -0
  45. package/tools/CLAUDE.md +191 -0
  46. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  52. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  53. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  54. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  55. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  56. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  57. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  58. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  59. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  60. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  61. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  62. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  63. package/tools/agent-forensics.sh +186 -0
  64. package/tools/buildlog.py +182 -0
  65. package/tools/ci_merge_wait.py +204 -0
  66. package/tools/ensure_state.py +94 -0
  67. package/tools/eod_sweep.py +239 -0
  68. package/tools/fleet_ledger.py +272 -0
  69. package/tools/heartbeat.py +131 -0
  70. package/tools/inbox_drain.py +249 -0
  71. package/tools/launch_tui.py +51 -3
  72. package/tools/lock.mjs +211 -0
  73. package/tools/metrics_gate.py +205 -0
  74. package/tools/orchestrator_status.py +99 -0
  75. package/tools/power_selftest.py +386 -0
  76. package/tools/prepublish_scan.py +84 -0
  77. package/tools/proposals.mjs +248 -0
  78. package/tools/reconstitute.sh +467 -0
  79. package/tools/rotate_logs.py +228 -0
  80. package/tools/scanner_selftest.py +303 -0
  81. package/tools/secret_scan.py +131 -31
  82. package/tools/stall_check.py +172 -0
  83. package/tools/verify_dash.py +694 -0
  84. package/tools/verify_submit_encoding.py +194 -0
  85. package/ui/CLAUDE.md +109 -0
  86. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  87. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  88. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  89. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  90. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  91. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  92. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  93. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  94. package/ui/agents.py +179 -0
  95. package/ui/api/__init__.py +84 -0
  96. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  97. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  98. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  99. package/ui/api/submit.py +58 -0
  100. package/ui/api/tracker.py +116 -0
  101. package/ui/collectors.py +586 -0
  102. package/ui/config.py +114 -0
  103. package/ui/csrf.py +140 -0
  104. package/ui/handler.py +426 -0
  105. package/ui/render.py +29 -0
  106. package/ui/serve.py +68 -796
  107. package/ui/sse.py +168 -0
  108. package/ui/templates/dashboard.html +1202 -0
@@ -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