loki-mode 5.52.4 → 5.54.0

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/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v5.52.4
6
+ # Loki Mode v5.54.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -263,4 +263,4 @@ The following features are documented in skill modules but not yet fully automat
263
263
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
264
264
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
265
265
 
266
- **v5.52.4 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
266
+ **v5.54.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 5.52.4
1
+ 5.54.0
package/autonomy/loki CHANGED
@@ -409,7 +409,8 @@ show_help() {
409
409
  echo " council [cmd] Completion council (status|verdicts|convergence|force-review|report)"
410
410
  echo " checkpoint|cp Save/restore session checkpoints"
411
411
  echo " projects Multi-project registry management"
412
- echo " audit Agent action audit log"
412
+ echo " audit [cmd] Agent audit log and quality scanning (log|scan)"
413
+ echo " optimize Optimize prompts based on session history"
413
414
  echo " enterprise Enterprise feature management (tokens, OIDC)"
414
415
  echo " metrics Prometheus/OpenMetrics metrics from dashboard"
415
416
  echo " dogfood Show self-development statistics"
@@ -431,6 +432,7 @@ show_help() {
431
432
  echo " --no-dashboard Disable web dashboard"
432
433
  echo " --sandbox Run in Docker sandbox for isolation"
433
434
  echo " --skip-memory Skip loading memory context at startup"
435
+ echo " --compliance PRESET Enable compliance mode (default|healthcare|fintech|government)"
434
436
  echo " --budget USD Set cost budget limit (display in dashboard/status)"
435
437
  echo ""
436
438
  echo "Options for 'issue':"
@@ -484,6 +486,7 @@ cmd_start() {
484
486
  echo " --no-dashboard Disable web dashboard"
485
487
  echo " --sandbox Run in Docker sandbox"
486
488
  echo " --skip-memory Skip loading memory context at startup"
489
+ echo " --compliance PRESET Enable compliance mode (default|healthcare|fintech|government)"
487
490
  echo " --budget USD Cost budget limit (auto-pause when exceeded)"
488
491
  echo " --yes, -y Skip confirmation prompts (auto-confirm)"
489
492
  echo ""
@@ -550,6 +553,22 @@ cmd_start() {
550
553
  export LOKI_SKIP_MEMORY=true
551
554
  shift
552
555
  ;;
556
+ --compliance)
557
+ if [[ -n "${2:-}" ]]; then
558
+ export LOKI_COMPLIANCE_PRESET="$2"
559
+ echo -e "${CYAN}Compliance mode: $2${NC}"
560
+ shift 2
561
+ else
562
+ echo -e "${RED}--compliance requires a preset name (default, healthcare, fintech, government)${NC}"
563
+ exit 1
564
+ fi
565
+ ;;
566
+ --compliance=*)
567
+ local compliance_val="${1#*=}"
568
+ export LOKI_COMPLIANCE_PRESET="$compliance_val"
569
+ echo -e "${CYAN}Compliance mode: $compliance_val${NC}"
570
+ shift
571
+ ;;
553
572
  --yes|-y)
554
573
  export LOKI_AUTO_CONFIRM=true
555
574
  shift
@@ -4688,6 +4707,108 @@ except Exception:
4688
4707
  esac
4689
4708
  }
4690
4709
 
4710
+ # Prompt optimization via dashboard API
4711
+ cmd_optimize() {
4712
+ local sessions=10
4713
+ local dry_run=""
4714
+
4715
+ while [[ $# -gt 0 ]]; do
4716
+ case "$1" in
4717
+ --sessions) if [[ -z "${2:-}" ]]; then echo -e "${RED}Error: --sessions requires a value${NC}"; exit 1; fi; sessions="$2"; shift 2 ;;
4718
+ --sessions=*) sessions="${1#*=}"; shift ;;
4719
+ --dry-run) dry_run="true"; shift ;;
4720
+ --help|-h)
4721
+ echo -e "${BOLD}loki optimize${NC} - Optimize prompts based on session history"
4722
+ echo ""
4723
+ echo "Usage: loki optimize [options]"
4724
+ echo ""
4725
+ echo "Analyzes recent session failures and proposes prompt improvements."
4726
+ echo "Requires the dashboard API to be running (loki serve)."
4727
+ echo ""
4728
+ echo "Options:"
4729
+ echo " --sessions N Number of recent sessions to analyze (default: 10)"
4730
+ echo " --dry-run Show proposed changes without applying"
4731
+ echo ""
4732
+ echo "Examples:"
4733
+ echo " loki optimize # Optimize using last 10 sessions"
4734
+ echo " loki optimize --sessions 20 # Analyze last 20 sessions"
4735
+ echo " loki optimize --dry-run # Preview changes only"
4736
+ exit 0
4737
+ ;;
4738
+ *)
4739
+ echo -e "${RED}Unknown option: $1${NC}"
4740
+ echo "Run 'loki optimize --help' for usage."
4741
+ exit 1
4742
+ ;;
4743
+ esac
4744
+ done
4745
+
4746
+ local port="${LOKI_DASHBOARD_PORT:-57374}"
4747
+ local host="127.0.0.1"
4748
+ local dry_run_param="false"
4749
+ if [ -n "$dry_run" ]; then
4750
+ dry_run_param="true"
4751
+ fi
4752
+ local url="http://${host}:${port}/api/prompt-optimize?sessions=${sessions}&dry_run=${dry_run_param}"
4753
+
4754
+ echo -e "${BOLD}Analyzing sessions for prompt optimization...${NC}"
4755
+ echo -e "${DIM}Sessions: $sessions | Dry run: ${dry_run_param}${NC}"
4756
+ echo ""
4757
+
4758
+ local response http_code
4759
+ response=$(curl -s -w "\n%{http_code}" -X POST "$url" 2>/dev/null) || true
4760
+ http_code=$(echo "$response" | tail -1)
4761
+ response=$(echo "$response" | sed '$d')
4762
+ if [ -z "$http_code" ] || [ "$http_code" = "000" ]; then
4763
+ echo -e "${RED}Error: Could not connect to dashboard API at http://${host}:${port}${NC}"
4764
+ echo "Make sure the dashboard is running: loki serve"
4765
+ exit 1
4766
+ fi
4767
+ if [ "$http_code" -ge 400 ] 2>/dev/null; then
4768
+ echo -e "${RED}Error: Dashboard API returned HTTP $http_code${NC}"
4769
+ [ -n "$response" ] && echo "$response"
4770
+ exit 1
4771
+ fi
4772
+
4773
+ if ! command -v python3 &>/dev/null; then
4774
+ echo "$response" | jq . 2>/dev/null || echo "$response"
4775
+ else
4776
+ echo "$response" | python3 -c "
4777
+ import json, sys
4778
+ data = json.loads(sys.stdin.read())
4779
+
4780
+ failures = data.get('failures_analyzed', 0)
4781
+ changes = data.get('changes', [])
4782
+ version = data.get('version', None)
4783
+
4784
+ print(f'Failures analyzed: {failures}')
4785
+ print(f'Changes proposed: {len(changes)}')
4786
+ print('---')
4787
+
4788
+ for i, change in enumerate(changes, 1):
4789
+ prompt = change.get('prompt', '?')
4790
+ rationale = change.get('rationale', 'No rationale provided')
4791
+ old = change.get('old_value', '')
4792
+ new = change.get('new_value', '')
4793
+ print(f'')
4794
+ print(f' {i}. {prompt}')
4795
+ print(f' Rationale: {rationale}')
4796
+ if old:
4797
+ print(f' Old: {old[:80]}...' if len(old) > 80 else f' Old: {old}')
4798
+ if new:
4799
+ print(f' New: {new[:80]}...' if len(new) > 80 else f' New: {new}')
4800
+
4801
+ if version is not None:
4802
+ print(f'')
4803
+ print(f'Prompts optimized to version {version}')
4804
+ elif not changes:
4805
+ print(f'')
4806
+ print('No optimization changes proposed.')
4807
+ print()
4808
+ " 2>/dev/null || echo "$response"
4809
+ fi
4810
+ }
4811
+
4691
4812
  # Main command dispatcher
4692
4813
  main() {
4693
4814
  if [ $# -eq 0 ]; then
@@ -4803,6 +4924,9 @@ main() {
4803
4924
  audit)
4804
4925
  cmd_audit "$@"
4805
4926
  ;;
4927
+ optimize)
4928
+ cmd_optimize "$@"
4929
+ ;;
4806
4930
  metrics)
4807
4931
  cmd_metrics "$@"
4808
4932
  ;;
@@ -4826,7 +4950,7 @@ main() {
4826
4950
  esac
4827
4951
  }
4828
4952
 
4829
- # Agent action audit log
4953
+ # Agent action audit log and quality scanning
4830
4954
  cmd_audit() {
4831
4955
  local subcommand="${1:-help}"
4832
4956
  local audit_file="$LOKI_DIR/logs/agent-audit.jsonl"
@@ -4885,16 +5009,138 @@ print(f' {\"TOTAL\":25s} {sum(counts.values())}')
4885
5009
  echo " python3 required for count summary"
4886
5010
  fi
4887
5011
  ;;
5012
+ scan)
5013
+ shift
5014
+ local preset="default"
5015
+ local do_export=""
5016
+
5017
+ while [[ $# -gt 0 ]]; do
5018
+ case "$1" in
5019
+ --preset) if [[ -z "${2:-}" ]]; then echo -e "${RED}Error: --preset requires a value${NC}"; exit 1; fi; preset="$2"; shift 2 ;;
5020
+ --preset=*) preset="${1#*=}"; shift ;;
5021
+ --export) do_export="true"; shift ;;
5022
+ --help|-h)
5023
+ echo -e "${BOLD}loki audit scan${NC} - Run quality scan"
5024
+ echo ""
5025
+ echo "Usage: loki audit scan [options]"
5026
+ echo ""
5027
+ echo "Options:"
5028
+ echo " --preset NAME Compliance preset (default|healthcare|fintech|government)"
5029
+ echo " --export Save report to .loki/quality/report-{date}.json"
5030
+ echo ""
5031
+ exit 0
5032
+ ;;
5033
+ *) echo -e "${RED}Unknown option: $1${NC}"; exit 1 ;;
5034
+ esac
5035
+ done
5036
+
5037
+ case "$preset" in
5038
+ default|healthcare|fintech|government) ;;
5039
+ *) echo -e "${RED}Error: Invalid preset '$preset'. Must be one of: default, healthcare, fintech, government${NC}"; exit 1 ;;
5040
+ esac
5041
+
5042
+ local port="${LOKI_DASHBOARD_PORT:-57374}"
5043
+ local host="127.0.0.1"
5044
+ local url="http://${host}:${port}/api/quality-scan?preset=${preset}"
5045
+
5046
+ echo -e "${BOLD}Running quality scan...${NC} (preset: $preset)"
5047
+ echo ""
5048
+
5049
+ local response http_code
5050
+ response=$(curl -s -w "\n%{http_code}" -X POST "$url" 2>/dev/null) || true
5051
+ http_code=$(echo "$response" | tail -1)
5052
+ response=$(echo "$response" | sed '$d')
5053
+ if [ -z "$http_code" ] || [ "$http_code" = "000" ]; then
5054
+ echo -e "${RED}Error: Could not connect to dashboard API at http://${host}:${port}${NC}"
5055
+ echo "Make sure the dashboard is running: loki serve"
5056
+ exit 1
5057
+ fi
5058
+ if [ "$http_code" -ge 400 ] 2>/dev/null; then
5059
+ echo -e "${RED}Error: Dashboard API returned HTTP $http_code${NC}"
5060
+ [ -n "$response" ] && echo "$response"
5061
+ exit 1
5062
+ fi
5063
+
5064
+ if ! command -v python3 &>/dev/null; then
5065
+ echo "$response" | jq . 2>/dev/null || echo "$response"
5066
+ else
5067
+ echo "$response" | python3 -c "
5068
+ import json, sys
5069
+ data = json.loads(sys.stdin.read())
5070
+
5071
+ score = data.get('score', 0)
5072
+ grade = data.get('grade', '?')
5073
+ print(f'Quality Score: {score}/100 Grade: {grade}')
5074
+ print('---')
5075
+
5076
+ # Category scores
5077
+ categories = data.get('categories', {})
5078
+ if categories:
5079
+ print()
5080
+ print('Category Scores:')
5081
+ for cat, cat_score in sorted(categories.items()):
5082
+ print(f' {cat:20s} {cat_score}/100')
5083
+
5084
+ # Findings by severity
5085
+ findings = data.get('findings', [])
5086
+ if findings:
5087
+ print()
5088
+ print('Findings:')
5089
+ for f in findings:
5090
+ sev = f.get('severity', 'info')
5091
+ msg = f.get('message', '')
5092
+ cat = f.get('category', '')
5093
+ if sev == 'critical':
5094
+ print(f' \033[0;31m[CRITICAL]\033[0m {cat}: {msg}')
5095
+ elif sev == 'major':
5096
+ print(f' \033[1;33m[MAJOR]\033[0m {cat}: {msg}')
5097
+ else:
5098
+ print(f' [MINOR] {cat}: {msg}')
5099
+ print()
5100
+ " 2>/dev/null || echo "$response"
5101
+ fi
5102
+
5103
+ # Export report if requested
5104
+ if [ -n "$do_export" ]; then
5105
+ local report_dir="$LOKI_DIR/quality"
5106
+ mkdir -p "$report_dir"
5107
+ local date_str
5108
+ date_str=$(date +%Y-%m-%d)
5109
+ local report_file="$report_dir/report-${date_str}.json"
5110
+
5111
+ local report_url="http://${host}:${port}/api/quality-report?format=json"
5112
+ local report_data
5113
+ report_data=$(curl -sf "$report_url" 2>/dev/null) || true
5114
+ if [ -n "$report_data" ]; then
5115
+ echo "$report_data" > "$report_file"
5116
+ echo -e "${GREEN}Report exported to $report_file${NC}"
5117
+ else
5118
+ # Fall back to scan response
5119
+ echo "$response" > "$report_file"
5120
+ echo -e "${GREEN}Report exported to $report_file${NC}"
5121
+ fi
5122
+ fi
5123
+ ;;
5124
+ --preset|--export)
5125
+ # Handle flags passed directly to 'loki audit' (shortcut for 'loki audit scan')
5126
+ cmd_audit scan "$@"
5127
+ return
5128
+ ;;
4888
5129
  --help|-h|help)
4889
- echo -e "${BOLD}loki audit${NC} - Agent action audit log"
5130
+ echo -e "${BOLD}loki audit${NC} - Agent audit log and quality scanning"
4890
5131
  echo ""
4891
- echo "Usage: loki audit <subcommand>"
5132
+ echo "Usage: loki audit <subcommand> [options]"
4892
5133
  echo ""
4893
5134
  echo "Subcommands:"
4894
5135
  echo " log [N] Show last N audit log entries (default: 50)"
4895
5136
  echo " count Count actions by type"
5137
+ echo " scan Run quality scan against dashboard API"
4896
5138
  echo " help Show this help"
4897
5139
  echo ""
5140
+ echo "Quality Scan Options (loki audit scan):"
5141
+ echo " --preset NAME Compliance preset (default|healthcare|fintech|government)"
5142
+ echo " --export Save report to .loki/quality/report-{date}.json"
5143
+ echo ""
4898
5144
  echo "The agent audit log records actions taken during Loki sessions,"
4899
5145
  echo "including CLI invocations, git commits, and session lifecycle events."
4900
5146
  echo "Log file: $audit_file"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "5.52.4"
10
+ __version__ = "5.54.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -0,0 +1,231 @@
1
+ """
2
+ Activity Logger for Loki Mode Dashboard.
3
+
4
+ Appends structured JSONL entries to ~/.loki/activity.jsonl with automatic
5
+ rotation at 10MB. Provides query and session-diff capabilities.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import threading
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ logger = logging.getLogger("loki-activity")
17
+
18
+ LOKI_DATA_DIR = os.environ.get("LOKI_DATA_DIR", os.path.expanduser("~/.loki"))
19
+
20
+ # Valid entity types and actions for validation
21
+ VALID_ENTITY_TYPES = {"task", "agent", "phase", "checkpoint"}
22
+ VALID_ACTIONS = {"created", "status_changed", "completed", "failed", "blocked"}
23
+
24
+ # Rotation threshold in bytes (10MB)
25
+ MAX_FILE_SIZE = 10 * 1024 * 1024
26
+
27
+
28
+ class ActivityLogger:
29
+ """Thread-safe activity logger that writes JSONL to ~/.loki/activity.jsonl."""
30
+
31
+ def __init__(self, data_dir: Optional[str] = None) -> None:
32
+ self._data_dir = Path(data_dir or LOKI_DATA_DIR)
33
+ self._log_file = self._data_dir / "activity.jsonl"
34
+ self._lock = threading.Lock()
35
+ self._data_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ @property
38
+ def log_file(self) -> Path:
39
+ """Return the path to the current activity log file."""
40
+ return self._log_file
41
+
42
+ def _rotate_if_needed(self) -> None:
43
+ """Rotate the log file if it exceeds MAX_FILE_SIZE."""
44
+ try:
45
+ if self._log_file.exists() and self._log_file.stat().st_size >= MAX_FILE_SIZE:
46
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
47
+ rotated = self._data_dir / f"activity-{timestamp}.jsonl"
48
+ self._log_file.rename(rotated)
49
+ logger.info("Rotated activity log to %s", rotated)
50
+ except OSError as e:
51
+ logger.warning("Failed to rotate activity log: %s", e)
52
+
53
+ def log(
54
+ self,
55
+ entity_type: str,
56
+ entity_id: str,
57
+ action: str,
58
+ old_value: Optional[str] = None,
59
+ new_value: Optional[str] = None,
60
+ session_id: Optional[str] = None,
61
+ ) -> dict[str, Any]:
62
+ """Log an activity entry. Returns the entry dict."""
63
+ if entity_type not in VALID_ENTITY_TYPES:
64
+ logger.warning("Invalid entity_type %r (valid: %s)", entity_type, VALID_ENTITY_TYPES)
65
+ if action not in VALID_ACTIONS:
66
+ logger.warning("Invalid action %r (valid: %s)", action, VALID_ACTIONS)
67
+
68
+ entry: dict[str, Any] = {
69
+ "timestamp": datetime.now(timezone.utc).isoformat(),
70
+ "entity_type": entity_type,
71
+ "entity_id": entity_id,
72
+ "action": action,
73
+ "old_value": old_value,
74
+ "new_value": new_value,
75
+ "session_id": session_id,
76
+ }
77
+
78
+ with self._lock:
79
+ self._rotate_if_needed()
80
+ try:
81
+ with open(self._log_file, "a", encoding="utf-8") as f:
82
+ f.write(json.dumps(entry, separators=(",", ":")) + "\n")
83
+ except OSError as e:
84
+ logger.error("Failed to write activity entry: %s", e)
85
+
86
+ return entry
87
+
88
+ def query_since(self, timestamp: str) -> list[dict[str, Any]]:
89
+ """Return activity entries after the given ISO timestamp."""
90
+ # Normalize Z-suffix so comparisons work consistently
91
+ timestamp = timestamp.replace("Z", "+00:00")
92
+ results: list[dict[str, Any]] = []
93
+
94
+ if not self._log_file.exists():
95
+ return results
96
+
97
+ with self._lock:
98
+ try:
99
+ with open(self._log_file, "r", encoding="utf-8") as f:
100
+ for line in f:
101
+ line = line.strip()
102
+ if not line:
103
+ continue
104
+ try:
105
+ entry = json.loads(line)
106
+ entry_ts = entry.get("timestamp", "").replace("Z", "+00:00")
107
+ if entry_ts > timestamp:
108
+ results.append(entry)
109
+ except json.JSONDecodeError:
110
+ continue
111
+ except OSError as e:
112
+ logger.error("Failed to read activity log: %s", e)
113
+
114
+ return results
115
+
116
+ def get_session_diff(self, since_timestamp: Optional[str] = None) -> dict[str, Any]:
117
+ """Return a structured summary of activity since the given timestamp.
118
+
119
+ If no timestamp is provided, defaults to the last 24 hours.
120
+ """
121
+ if since_timestamp is None:
122
+ since_dt = datetime.now(timezone.utc) - timedelta(hours=24)
123
+ since_timestamp = since_dt.isoformat()
124
+
125
+ # Normalize Z-suffix before passing to query_since
126
+ since_timestamp = since_timestamp.replace("Z", "+00:00")
127
+
128
+ entries = self.query_since(since_timestamp)
129
+
130
+ now = datetime.now(timezone.utc)
131
+ try:
132
+ since_dt = datetime.fromisoformat(since_timestamp.replace("Z", "+00:00"))
133
+ except (ValueError, AttributeError):
134
+ since_dt = now
135
+
136
+ period_hours = max(0.0, (now - since_dt).total_seconds() / 3600)
137
+
138
+ # Build summary counts
139
+ summary = {
140
+ "total_changes": len(entries),
141
+ "tasks_created": 0,
142
+ "tasks_completed": 0,
143
+ "tasks_blocked": 0,
144
+ "phases_transitioned": 0,
145
+ "checkpoints_created": 0,
146
+ "errors": 0,
147
+ }
148
+
149
+ highlights: list[str] = []
150
+ decisions: list[dict[str, str]] = []
151
+
152
+ for entry in entries:
153
+ entity_type = entry.get("entity_type", "")
154
+ action = entry.get("action", "")
155
+ entity_id = entry.get("entity_id", "")
156
+
157
+ if entity_type == "task":
158
+ if action == "created":
159
+ summary["tasks_created"] += 1
160
+ highlights.append(f"Task {entity_id} created")
161
+ elif action == "completed":
162
+ summary["tasks_completed"] += 1
163
+ highlights.append(f"Task {entity_id} completed")
164
+ elif action == "blocked":
165
+ summary["tasks_blocked"] += 1
166
+ highlights.append(f"Task {entity_id} blocked")
167
+ elif action == "failed":
168
+ summary["errors"] += 1
169
+ highlights.append(f"Task {entity_id} failed")
170
+ elif action == "status_changed":
171
+ old_val = entry.get("old_value", "")
172
+ new_val = entry.get("new_value", "")
173
+ highlights.append(f"Task {entity_id}: {old_val} -> {new_val}")
174
+
175
+ elif entity_type == "agent":
176
+ if action == "failed":
177
+ summary["errors"] += 1
178
+ highlights.append(f"Agent {entity_id} failed")
179
+ elif action == "created":
180
+ highlights.append(f"Agent {entity_id} created")
181
+ elif action == "status_changed":
182
+ old_val = entry.get("old_value", "")
183
+ new_val = entry.get("new_value", "")
184
+ highlights.append(f"Agent {entity_id}: {old_val} -> {new_val}")
185
+ # Agent status changes may represent decisions
186
+ if new_val:
187
+ decisions.append({
188
+ "timestamp": entry.get("timestamp", ""),
189
+ "decision": f"Agent {entity_id} transitioned to {new_val}",
190
+ "reasoning": f"Status changed from {old_val} to {new_val}",
191
+ })
192
+
193
+ elif entity_type == "phase":
194
+ if action == "status_changed":
195
+ summary["phases_transitioned"] += 1
196
+ old_val = entry.get("old_value", "")
197
+ new_val = entry.get("new_value", "")
198
+ highlights.append(f"Phase transition: {old_val} -> {new_val}")
199
+ decisions.append({
200
+ "timestamp": entry.get("timestamp", ""),
201
+ "decision": f"Phase transitioned to {new_val}",
202
+ "reasoning": f"Moved from {old_val} to {new_val}",
203
+ })
204
+
205
+ elif entity_type == "checkpoint":
206
+ if action == "created":
207
+ summary["checkpoints_created"] += 1
208
+ highlights.append(f"Checkpoint {entity_id} created")
209
+
210
+ return {
211
+ "since": since_timestamp,
212
+ "period_hours": round(period_hours, 2),
213
+ "summary": summary,
214
+ "highlights": highlights,
215
+ "decisions": decisions,
216
+ }
217
+
218
+
219
+ # Singleton instance
220
+ _instance: Optional[ActivityLogger] = None
221
+ _instance_lock = threading.Lock()
222
+
223
+
224
+ def get_activity_logger(data_dir: Optional[str] = None) -> ActivityLogger:
225
+ """Get or create the singleton ActivityLogger instance."""
226
+ global _instance
227
+ if _instance is None:
228
+ with _instance_lock:
229
+ if _instance is None:
230
+ _instance = ActivityLogger(data_dir=data_dir)
231
+ return _instance