@matt82198/aesop 0.1.0-beta.4 → 0.1.0-rc.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 (134) hide show
  1. package/CHANGELOG.md +187 -5
  2. package/LICENSE +66 -21
  3. package/README.md +143 -23
  4. package/aesop.config.example.json +14 -1
  5. package/bin/CLAUDE.md +42 -5
  6. package/bin/cli.js +479 -95
  7. package/daemons/CLAUDE.md +5 -2
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +211 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/RELEASING.md +159 -0
  19. package/docs/archive/README.md +3 -0
  20. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  21. package/docs/autonomous-swe.md +149 -0
  22. package/docs/case-study-portfolio.md +61 -0
  23. package/docs/reproduce.md +121 -0
  24. package/docs/self-stats-data.json +11 -0
  25. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  26. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  27. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  28. package/hooks/CLAUDE.md +51 -0
  29. package/hooks/install-waveguard.sh +68 -0
  30. package/hooks/pre-commit-waveguard.sh +26 -0
  31. package/hooks/pre-push-policy.sh +253 -15
  32. package/mcp/CLAUDE.md +213 -0
  33. package/mcp/package.json +26 -0
  34. package/mcp/server.mjs +543 -0
  35. package/monitor/CHARTER.md +76 -110
  36. package/monitor/CLAUDE.md +3 -3
  37. package/monitor/collect-signals.mjs +218 -20
  38. package/package.json +17 -6
  39. package/scan/CLAUDE.md +30 -0
  40. package/scan/fleet-scan.example.mjs +292 -0
  41. package/skills/CLAUDE.md +1 -0
  42. package/skills/healthcheck/SKILL.md +44 -0
  43. package/state_store/CLAUDE.md +39 -0
  44. package/state_store/__init__.py +24 -0
  45. package/state_store/api.py +35 -0
  46. package/state_store/export.py +19 -0
  47. package/state_store/ingest.py +41 -0
  48. package/state_store/projections.py +126 -0
  49. package/state_store/store.py +197 -0
  50. package/tools/CLAUDE.md +37 -149
  51. package/tools/alert_bridge.py +453 -0
  52. package/tools/bench_runner.py +438 -0
  53. package/tools/buildlog.py +4 -7
  54. package/tools/ci_merge_wait.py +308 -14
  55. package/tools/common.py +62 -0
  56. package/tools/cost_ceiling.py +191 -0
  57. package/tools/dash.js +102 -0
  58. package/tools/doctor.js +220 -0
  59. package/tools/fleet_ledger.py +189 -17
  60. package/tools/fleet_prompt_extractor.py +134 -0
  61. package/tools/halt.py +172 -0
  62. package/tools/healthcheck.py +299 -0
  63. package/tools/heartbeat.py +4 -7
  64. package/tools/metrics_gate.py +8 -2
  65. package/tools/orchestrator_status.py +4 -9
  66. package/tools/reconcile.py +277 -0
  67. package/tools/rotate_logs.py +152 -62
  68. package/tools/scanner_selftest.py +28 -3
  69. package/tools/secret_scan.py +367 -91
  70. package/tools/self_stats.py +797 -0
  71. package/tools/session_usage_summary.py +198 -0
  72. package/tools/status.js +187 -0
  73. package/tools/svg_to_png.mjs +50 -0
  74. package/tools/transcript_replay.py +236 -0
  75. package/tools/transcript_timeline.py +184 -0
  76. package/tools/verify_agent_inspector.py +289 -0
  77. package/tools/verify_dash.py +361 -542
  78. package/tools/verify_prboard.py +344 -0
  79. package/tools/verify_submit_encoding.py +12 -4
  80. package/tools/watch.js +49 -0
  81. package/ui/CLAUDE.md +59 -41
  82. package/ui/agents.py +337 -34
  83. package/ui/api/submit.py +44 -5
  84. package/ui/api/tracker.py +15 -7
  85. package/ui/collectors.py +294 -52
  86. package/ui/config.py +31 -2
  87. package/ui/cost.py +355 -0
  88. package/ui/csrf.py +9 -4
  89. package/ui/handler.py +450 -12
  90. package/ui/render.py +26 -6
  91. package/ui/sse.py +114 -13
  92. package/ui/wave_prs.py +230 -0
  93. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  94. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  95. package/ui/web/dist/index.html +14 -0
  96. package/monitor/.signal-state.json +0 -3
  97. package/monitor/ACTIONS.log +0 -1
  98. package/monitor/BRIEF.md +0 -24
  99. package/monitor/SIGNALS.json +0 -54
  100. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  104. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  105. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  106. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  107. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  108. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  109. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  110. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  111. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  112. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  113. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  114. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  115. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  116. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  117. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  118. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  119. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  120. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  121. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  122. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  123. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  124. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  125. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  126. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  127. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  128. package/ui/templates/dashboard.html +0 -1202
  129. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  130. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  131. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  132. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  133. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  134. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
package/ui/collectors.py CHANGED
@@ -233,7 +233,7 @@ def get_main_thread_messages():
233
233
  return messages
234
234
 
235
235
  newest = jsonl_files[0]
236
- with open(newest, 'r', encoding='utf-8', errors='ignore') as f:
236
+ with open(newest, 'r', encoding='utf-8', errors='replace') as f:
237
237
  lines = f.readlines()
238
238
  # Get last 30 lines to extract ~12 message turns
239
239
  for line in lines[-30:]:
@@ -316,7 +316,27 @@ def get_alerts():
316
316
  return alerts
317
317
 
318
318
  def load_tracker():
319
- """Load tracker.json, return empty tracker if missing or corrupt."""
319
+ """Load tracker.json, return empty tracker if missing or corrupt.
320
+
321
+ Defect 2 fix: Check dirty flag. If render previously failed, the event log
322
+ and tracker.json may be out of sync. Re-render from projection to self-heal.
323
+ """
324
+ # Check dirty flag (indicates previous render failure)
325
+ dirty_file = config.STATE_DIR / ".tracker-render-dirty"
326
+ if dirty_file.exists():
327
+ try:
328
+ print("[tracker] Detected previous render failure; recovering...", file=sys.stderr)
329
+ api = _tracker_api()
330
+ # Force re-render from projection
331
+ try:
332
+ save_tracker(api.project("tracker"))
333
+ dirty_file.unlink()
334
+ print("[tracker] Recovery render completed", file=sys.stderr)
335
+ except Exception as e:
336
+ print(f"[tracker] Recovery render failed: {e}", file=sys.stderr)
337
+ except Exception as e:
338
+ print(f"[tracker] Failed to recover from dirty flag: {e}", file=sys.stderr)
339
+
320
340
  if not config.TRACKER_FILE.exists():
321
341
  return {"version": 1, "items": []}
322
342
 
@@ -418,9 +438,95 @@ def get_tracker_items(status=None, priority=None):
418
438
 
419
439
  return items
420
440
 
441
+ # --- Event-sourced tracker backing (state_store) -----------------------------
442
+ # The event log is the write-authority + audit trail; tracker.json is kept as
443
+ # the rendered export that reads/SSE/UI consume. Each mutation appends an event
444
+ # and re-renders the file (atomic os.replace via save_tracker), replacing the
445
+ # old load->mutate->save read-modify-write that raced under concurrent writers.
446
+ # The live read path (load_tracker / get_tracker_items / SSE snapshot) is
447
+ # unchanged: it still reads tracker.json, which every write keeps current.
448
+
449
+ def _tracker_api():
450
+ """Return a StateAPI over state/tracker_events.db (lazy import; call-time paths)."""
451
+ try:
452
+ from state_store import StateAPI
453
+ except ImportError:
454
+ from pathlib import Path as _Path
455
+ root = str(_Path(__file__).resolve().parents[1])
456
+ if root not in sys.path:
457
+ sys.path.insert(0, root)
458
+ from state_store import StateAPI
459
+ config.STATE_DIR.mkdir(parents=True, exist_ok=True)
460
+ return StateAPI(str(config.STATE_DIR / "tracker_events.db"))
461
+
462
+
463
+ def _ensure_tracker_migrated(api):
464
+ """Backfill the event log from the existing tracker.json once (idempotent).
465
+
466
+ Defect 3 fix: Guard migration with a marker event (migration_started with
467
+ version=1). The first caller to append this marker wins and performs the
468
+ backfill. Subsequent callers see the marker and skip the backfill. This
469
+ prevents concurrent callers from polluting the audit log with duplicate
470
+ item_created events.
471
+ """
472
+ events = api.get("tracker")
473
+
474
+ # Check for migration marker (first event of type "migration_started")
475
+ has_migration_marker = any(e.get("type") == "migration_started" and
476
+ e.get("payload", {}).get("version") == 1
477
+ for e in events)
478
+ if has_migration_marker:
479
+ # Migration already completed (or in progress); skip backfill
480
+ return
481
+
482
+ # Append migration marker first to atomically claim the migration
483
+ try:
484
+ api.append("tracker", "migration_started", {"version": 1}, "system")
485
+ except Exception as e:
486
+ print(f"[tracker] Failed to append migration marker: {e}", file=sys.stderr)
487
+ return
488
+
489
+ # Now safe to backfill (other callers will see marker and skip)
490
+ if config.TRACKER_FILE.exists():
491
+ try:
492
+ data = json.loads(config.TRACKER_FILE.read_text(encoding='utf-8'))
493
+ except Exception:
494
+ data = {"items": []}
495
+ for item in data.get("items", []):
496
+ if isinstance(item, dict) and item.get("id"):
497
+ api.append("tracker", "item_created", item, "migration")
498
+
499
+
500
+ def _tracker_items_by_id(api):
501
+ return {it["id"]: it for it in api.project("tracker")["items"]}
502
+
503
+
504
+ def _render_tracker(api):
505
+ """Materialize the projection back to tracker.json (atomic).
506
+
507
+ Defect 2 fix: Wrap render in try/except. On failure, log loudly and mark
508
+ a dirty flag so the next read triggers a recovery re-render. This ensures
509
+ that if the event log has events but tracker.json is stale, the next read
510
+ self-heals by detecting the mismatch and re-rendering.
511
+ """
512
+ try:
513
+ save_tracker(api.project("tracker"))
514
+ except Exception as e:
515
+ print(f"[tracker] CRITICAL: Failed to render tracker after event append: {e}",
516
+ file=sys.stderr)
517
+ # Mark dirty flag for recovery on next read
518
+ dirty_file = config.STATE_DIR / ".tracker-render-dirty"
519
+ try:
520
+ dirty_file.write_text(str(time()), encoding='utf-8')
521
+ except Exception as e2:
522
+ print(f"[tracker] Failed to write dirty flag: {e2}", file=sys.stderr)
523
+ raise
524
+
525
+
421
526
  def create_tracker_item(data):
422
- """Create a new tracker item."""
423
- tracker = load_tracker()
527
+ """Create a new tracker item (event-sourced; tracker.json re-rendered)."""
528
+ api = _tracker_api()
529
+ _ensure_tracker_migrated(api)
424
530
 
425
531
  item = {
426
532
  "id": secrets.token_hex(6),
@@ -436,39 +542,43 @@ def create_tracker_item(data):
436
542
  "completed_at": None
437
543
  }
438
544
 
439
- tracker["items"].append(item)
440
- save_tracker(tracker)
545
+ api.append("tracker", "item_created", item, item["source"])
546
+ _render_tracker(api)
441
547
  return item
442
548
 
443
549
  def update_tracker_item(item_id, update_data):
444
- """Update a tracker item by id."""
445
- tracker = load_tracker()
550
+ """Update a tracker item by id (event-sourced)."""
551
+ api = _tracker_api()
552
+ _ensure_tracker_migrated(api)
446
553
 
447
- item = next((i for i in tracker["items"] if i["id"] == item_id), None)
448
- if not item:
554
+ current = _tracker_items_by_id(api)
555
+ if item_id not in current:
449
556
  raise Exception(f"404 Item not found: {item_id}")
450
557
 
558
+ patch = {"id": item_id}
451
559
  for key in ["status", "lane", "priority", "notes", "pr_link", "tags"]:
452
560
  if key in update_data:
453
- item[key] = update_data[key]
561
+ patch[key] = update_data[key]
454
562
 
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")
563
+ if update_data.get("status") == "done" and not current[item_id].get("completed_at"):
564
+ patch["completed_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
457
565
 
458
- save_tracker(tracker)
459
- return item
566
+ api.append("tracker", "item_updated", patch, "api")
567
+ _render_tracker(api)
568
+ return _tracker_items_by_id(api)[item_id]
460
569
 
461
570
  def delete_tracker_item(item_id):
462
- """Soft-delete a tracker item (mark as archived)."""
463
- tracker = load_tracker()
571
+ """Soft-delete a tracker item (mark as archived; event-sourced)."""
572
+ api = _tracker_api()
573
+ _ensure_tracker_migrated(api)
464
574
 
465
- item = next((i for i in tracker["items"] if i["id"] == item_id), None)
466
- if not item:
575
+ current = _tracker_items_by_id(api)
576
+ if item_id not in current:
467
577
  raise Exception(f"404 Item not found: {item_id}")
468
578
 
469
- item["status"] = "archived"
470
- save_tracker(tracker)
471
- return item
579
+ api.append("tracker", "item_archived", {"id": item_id}, "api")
580
+ _render_tracker(api)
581
+ return _tracker_items_by_id(api)[item_id]
472
582
 
473
583
  def _snapshot_data():
474
584
  """Everything the 'data' SSE section covers (header, repos, events, alerts, messages)."""
@@ -529,28 +639,155 @@ def _snapshot_orchestrator_status():
529
639
  print(f"[status] Snapshot error: {e}", file=sys.stderr)
530
640
  return {"orchestrators": []}
531
641
 
642
+ def _recover_stranded_inbox_files():
643
+ """Defect 3 fix: Recovery sweep for .tracker-inbox.jsonl.processing-* files.
644
+
645
+ If drain_tracker_inbox crashes mid-process, it leaves a .processing-* file.
646
+ This sweep re-ingests those files on the next drain call, preventing silent
647
+ data loss. Returns list of items recovered.
648
+ """
649
+ recovered = []
650
+ try:
651
+ for processing_file in sorted(config.STATE_DIR.glob(".tracker-inbox.jsonl.processing-*")):
652
+ if not processing_file.exists():
653
+ continue
654
+ try:
655
+ content = processing_file.read_text(encoding='utf-8')
656
+ if not content.strip():
657
+ try:
658
+ processing_file.unlink()
659
+ except Exception:
660
+ pass
661
+ continue
662
+
663
+ # Build dedup hash set from both tracker.json and event store
664
+ existing_hashes = set()
665
+
666
+ # Check rendered tracker.json
667
+ tracker_json = load_tracker()
668
+ for item in tracker_json.get("items", []):
669
+ source = item.get("source", "")
670
+ title = item.get("title", "")
671
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
672
+ existing_hashes.add(h)
673
+
674
+ # Check event store projection
675
+ try:
676
+ api = _tracker_api()
677
+ projected = api.project("tracker")
678
+ for item in projected.get("items", []):
679
+ source = item.get("source", "")
680
+ title = item.get("title", "")
681
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
682
+ existing_hashes.add(h)
683
+ except Exception as e:
684
+ print(f"[inbox] Recovery: failed to project tracker state: {e}", file=sys.stderr)
685
+
686
+ # Reprocess lines from the recovered file
687
+ lines = content.strip().splitlines()
688
+ for line in lines:
689
+ line = line.strip()
690
+ if not line:
691
+ continue
692
+ try:
693
+ entry = json.loads(line)
694
+ if not isinstance(entry, dict):
695
+ continue
696
+ source = entry.get("source", "")
697
+ title = entry.get("title", "")
698
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
699
+
700
+ if h not in existing_hashes:
701
+ item = create_tracker_item(entry)
702
+ recovered.append(item)
703
+ existing_hashes.add(h)
704
+ except (json.JSONDecodeError, Exception):
705
+ pass
706
+
707
+ # Only delete after successful re-ingest
708
+ try:
709
+ processing_file.unlink()
710
+ except Exception as e:
711
+ print(f"[inbox] Recovery: failed to unlink {processing_file}: {e}", file=sys.stderr)
712
+
713
+ except Exception as e:
714
+ print(f"[inbox] Recovery sweep error on {processing_file}: {e}", file=sys.stderr)
715
+ except Exception as e:
716
+ print(f"[inbox] Recovery sweep failed: {e}", file=sys.stderr)
717
+
718
+ return recovered
719
+
720
+
532
721
  def drain_tracker_inbox():
533
- """Drain .tracker-inbox.jsonl, create items idempotently."""
722
+ """Drain .tracker-inbox.jsonl, create items idempotently.
723
+
724
+ Defect 1 fix: Atomically rename inbox file to unique processing name FIRST
725
+ to ensure only one caller processes it under concurrent access. Strengthen
726
+ dedup to check both tracker.json AND api.project("tracker") so items
727
+ in the event store but not yet rendered are also excluded.
728
+
729
+ Defect 3 fix: Before processing the current inbox, perform a recovery sweep
730
+ to re-ingest leftover .tracker-inbox.jsonl.processing-* files from any
731
+ previous crashes. This prevents silent data loss if a crash happens
732
+ mid-drain: stranded files are recovered on the next drain call.
733
+ """
534
734
  inbox_file = config.STATE_DIR / ".tracker-inbox.jsonl"
735
+
736
+ # Defect 3: Recovery sweep for stranded .processing-* files from prior crashes.
737
+ created = _recover_stranded_inbox_files()
738
+
739
+ # Now process the current inbox if it exists
535
740
  if not inbox_file.exists():
536
- return []
537
-
538
- created = []
741
+ return created
742
+
743
+ # Defect 1: Atomically rename inbox to unique processing name first.
744
+ # This ensures only one caller wins; others see no file.
745
+ processing_file = inbox_file.with_name(
746
+ f".tracker-inbox.jsonl.processing-{secrets.token_hex(8)}"
747
+ )
748
+ try:
749
+ os.replace(str(inbox_file), str(processing_file))
750
+ except FileNotFoundError:
751
+ # Another caller already renamed it; nothing to process
752
+ return created
753
+ except Exception as e:
754
+ print(f"[inbox] Failed to rename inbox: {e}", file=sys.stderr)
755
+ return created
756
+
539
757
  try:
540
- content = inbox_file.read_text(encoding='utf-8')
758
+ content = processing_file.read_text(encoding='utf-8')
541
759
  if not content.strip():
542
- inbox_file.unlink()
543
- return []
544
-
760
+ processing_file.unlink()
761
+ return created
762
+
545
763
  lines = content.strip().splitlines()
546
- tracker = load_tracker()
764
+
765
+ # Defect 1: Build dedup hash set from both tracker.json AND event store projection.
766
+ # This catches items in the event store that haven't been rendered to tracker.json yet.
547
767
  existing_hashes = set()
548
- for item in tracker.get("items", []):
768
+
769
+ # Check rendered tracker.json
770
+ tracker_json = load_tracker()
771
+ for item in tracker_json.get("items", []):
549
772
  source = item.get("source", "")
550
773
  title = item.get("title", "")
551
774
  h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
552
775
  existing_hashes.add(h)
553
-
776
+
777
+ # Also check event store projection (catches items in DB but not yet rendered)
778
+ try:
779
+ api = _tracker_api()
780
+ projected = api.project("tracker")
781
+ for item in projected.get("items", []):
782
+ source = item.get("source", "")
783
+ title = item.get("title", "")
784
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
785
+ existing_hashes.add(h)
786
+ except Exception as e:
787
+ print(f"[inbox] Failed to project tracker state: {e}", file=sys.stderr)
788
+ # Fall back to tracker.json only if projection fails
789
+ pass
790
+
554
791
  rejects = []
555
792
  for line in lines:
556
793
  line = line.strip()
@@ -558,29 +795,34 @@ def drain_tracker_inbox():
558
795
  continue
559
796
  try:
560
797
  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
798
  except json.JSONDecodeError:
574
799
  rejects.append(line)
575
- except Exception as e:
576
- rejects.append(line + " # " + str(e))
577
-
800
+ continue
801
+
802
+ if not isinstance(entry, dict):
803
+ rejects.append(line)
804
+ continue
805
+
806
+ source = entry.get("source", "")
807
+ title = entry.get("title", "")
808
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
809
+
810
+ if h not in existing_hashes:
811
+ # create_tracker_item can raise real errors (not malformed JSON)
812
+ # let those bubble up rather than silently adding to rejects
813
+ item = create_tracker_item(entry)
814
+ created.append(item)
815
+ existing_hashes.add(h)
816
+
578
817
  if rejects:
579
818
  rejects_file = inbox_file.with_name(".tracker-inbox.rejects")
580
819
  rejects_file.write_text("\n".join(rejects) + "\n", encoding='utf-8')
581
-
582
- inbox_file.unlink()
820
+
821
+ # Only delete the processing file after successful completion.
822
+ # If an exception occurs, the file is left behind for recovery.
823
+ processing_file.unlink()
583
824
  except Exception as e:
584
- print(f"[inbox] Drain error: {e}", file=sys.stderr)
585
-
825
+ print(f"[inbox] Drain error: {e}; processing file {processing_file.name} left for recovery", file=sys.stderr)
826
+ # Don't delete the file on error; let recovery sweep handle it next time
827
+
586
828
  return created
package/ui/config.py CHANGED
@@ -28,13 +28,34 @@ def reload():
28
28
  global WATCHDOG_HEARTBEAT, MONITOR_HEARTBEAT, REPOS_JSON, BACKUP_LOG
29
29
  global ALERTS_LOG, INBOX_FILE, AUDIT_BACKLOG_FILE
30
30
  global UI_SESSION_TOKEN_FILE, TRACKER_FILE, ORCH_STATUS_FILE
31
+ global WEB_DIST, LEDGER_FILE
31
32
  global COLLECTOR_INTERVAL, SSE_KEEPALIVE_SECONDS, SSE_MAX_CLIENTS, SSE_QUEUE_MAXSIZE, SSE_WRITE_TIMEOUT
32
33
 
33
34
  # PORT: env PORT > default 8770
34
35
  PORT = int(os.getenv("PORT", "8770"))
35
36
 
36
- # Determine AESOP_ROOT: env AESOP_ROOT > default $HOME/aesop
37
- AESOP_ROOT = Path(os.getenv("AESOP_ROOT", Path.home() / "aesop"))
37
+ # Determine AESOP_ROOT with fallback tiers (matching daemons/run-watchdog.sh pattern):
38
+ # (1) AESOP_ROOT env var if set
39
+ # (2) Derive from file location: Path(__file__).resolve().parents[1]
40
+ # (3) Load config from derived location; if it has aesop_root, use that
41
+ env_root = os.getenv("AESOP_ROOT")
42
+ if env_root:
43
+ AESOP_ROOT = Path(env_root)
44
+ else:
45
+ # Derive from file location (matches daemons/run-watchdog.sh pattern)
46
+ AESOP_ROOT = Path(__file__).resolve().parents[1]
47
+
48
+ # Check if derived location's config has aesop_root key
49
+ derived_config_file = AESOP_ROOT / "aesop.config.json"
50
+ if derived_config_file.exists():
51
+ try:
52
+ with open(derived_config_file) as f:
53
+ derived_config = json.load(f)
54
+ if "aesop_root" in derived_config:
55
+ AESOP_ROOT = Path(derived_config["aesop_root"])
56
+ except Exception:
57
+ # Silently ignore config errors here; will attempt full load below
58
+ pass
38
59
 
39
60
  # Try to load config file for additional settings
40
61
  CONFIG_FILE = AESOP_ROOT / "aesop.config.json"
@@ -75,6 +96,12 @@ def reload():
75
96
  TRACKER_FILE = STATE_DIR / "tracker.json"
76
97
  ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
77
98
 
99
+ # Wave-14 dashboard rewrite (plan D3): built frontend + cost ledger paths.
100
+ # WEB_DIST: the committed Vite build output served at / and /assets/*.
101
+ WEB_DIST = AESOP_ROOT / "ui" / "web" / "dist"
102
+ # LEDGER_FILE: outcomes ledger parsed by ui/cost.py; sse.py mtime-gates on it.
103
+ LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
104
+
78
105
  # Collector and SSE configuration
79
106
  COLLECTOR_INTERVAL = float(os.getenv("AESOP_UI_COLLECT_INTERVAL", "1.0"))
80
107
  SSE_KEEPALIVE_SECONDS = 15
@@ -104,6 +131,8 @@ AUDIT_BACKLOG_FILE = AESOP_ROOT / "AUDIT-BACKLOG.md"
104
131
  UI_SESSION_TOKEN_FILE = STATE_DIR / ".ui-session-token"
105
132
  TRACKER_FILE = STATE_DIR / "tracker.json"
106
133
  ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
134
+ WEB_DIST = AESOP_ROOT / "ui" / "web" / "dist"
135
+ LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
107
136
  COLLECTOR_INTERVAL = 1.0
108
137
  SSE_KEEPALIVE_SECONDS = 15
109
138
  SSE_MAX_CLIENTS = 100