nexo-brain 2.6.21 → 3.0.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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +72 -20
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +296 -8
  6. package/src/cli.py +209 -4
  7. package/src/client_preferences.py +115 -0
  8. package/src/client_sync.py +202 -2
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +264 -0
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/dashboard.html +59 -1
  14. package/src/dashboard/templates/protocol.html +199 -0
  15. package/src/db/__init__.py +23 -1
  16. package/src/db/_learnings.py +31 -4
  17. package/src/db/_personal_scripts.py +12 -0
  18. package/src/db/_protocol.py +303 -0
  19. package/src/db/_schema.py +248 -0
  20. package/src/db/_watchers.py +173 -0
  21. package/src/db/_workflow.py +952 -0
  22. package/src/doctor/providers/runtime.py +1095 -3
  23. package/src/evolution_cycle.py +62 -0
  24. package/src/hook_guardrails.py +308 -0
  25. package/src/hooks/protocol-guardrail.sh +10 -0
  26. package/src/nexo_sdk.py +103 -0
  27. package/src/plugins/cognitive_memory.py +18 -0
  28. package/src/plugins/cortex.py +55 -35
  29. package/src/plugins/guard.py +132 -16
  30. package/src/plugins/protocol.py +911 -0
  31. package/src/plugins/schedule.py +40 -6
  32. package/src/plugins/simple_api.py +103 -0
  33. package/src/plugins/skills.py +67 -0
  34. package/src/plugins/state_watchers.py +79 -0
  35. package/src/plugins/workflow.py +588 -0
  36. package/src/public_contribution.py +86 -12
  37. package/src/script_registry.py +142 -0
  38. package/src/scripts/deep-sleep/apply_findings.py +482 -2
  39. package/src/scripts/deep-sleep/collect.py +49 -4
  40. package/src/scripts/nexo-agent-run.py +2 -0
  41. package/src/scripts/nexo-daily-self-audit.py +843 -5
  42. package/src/scripts/nexo-evolution-run.py +343 -1
  43. package/src/server.py +92 -6
  44. package/src/skills_runtime.py +151 -0
  45. package/src/state_watchers_runtime.py +334 -0
  46. package/src/tools_learnings.py +345 -7
  47. package/src/tools_sessions.py +183 -0
  48. package/templates/CLAUDE.md.template +9 -1
  49. package/templates/CODEX.AGENTS.md.template +10 -2
@@ -5,6 +5,8 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import json
7
7
  import os
8
+ import re
9
+ import shlex
8
10
  import shutil
9
11
  import subprocess
10
12
  import sys
@@ -308,6 +310,179 @@ def _write_json_object(path: Path, payload: dict) -> None:
308
310
  path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
309
311
 
310
312
 
313
+ CORE_HOOK_SPECS = [
314
+ {
315
+ "event": "SessionStart",
316
+ "identity": "session-start-ts",
317
+ "timeout": 2,
318
+ "command_template": lambda nexo_home, _runtime_root, _hooks_dir: (
319
+ f"mkdir -p {shlex.quote(str(nexo_home / 'operations'))} && "
320
+ f"date +%s > {shlex.quote(str(nexo_home / 'operations' / '.session-start-ts'))}"
321
+ ),
322
+ },
323
+ {
324
+ "event": "SessionStart",
325
+ "identity": "daily-briefing-check.sh",
326
+ "timeout": 5,
327
+ "script": "daily-briefing-check.sh",
328
+ },
329
+ {
330
+ "event": "SessionStart",
331
+ "identity": "session-start.sh",
332
+ "timeout": 35,
333
+ "script": "session-start.sh",
334
+ },
335
+ {
336
+ "event": "Stop",
337
+ "identity": "session-stop.sh",
338
+ "timeout": 10,
339
+ "script": "session-stop.sh",
340
+ },
341
+ {
342
+ "event": "PostToolUse",
343
+ "identity": "capture-tool-logs.sh",
344
+ "timeout": 5,
345
+ "script": "capture-tool-logs.sh",
346
+ },
347
+ {
348
+ "event": "PostToolUse",
349
+ "identity": "capture-session.sh",
350
+ "timeout": 3,
351
+ "script": "capture-session.sh",
352
+ },
353
+ {
354
+ "event": "PostToolUse",
355
+ "identity": "inbox-hook.sh",
356
+ "timeout": 5,
357
+ "script": "inbox-hook.sh",
358
+ },
359
+ {
360
+ "event": "PostToolUse",
361
+ "identity": "protocol-guardrail.sh",
362
+ "timeout": 5,
363
+ "script": "protocol-guardrail.sh",
364
+ },
365
+ {
366
+ "event": "PreCompact",
367
+ "identity": "pre-compact.sh",
368
+ "timeout": 10,
369
+ "script": "pre-compact.sh",
370
+ },
371
+ {
372
+ "event": "PostCompact",
373
+ "identity": "post-compact.sh",
374
+ "timeout": 10,
375
+ "script": "post-compact.sh",
376
+ },
377
+ ]
378
+
379
+
380
+ def _resolve_hook_source_dir(runtime_root: Path) -> Path:
381
+ direct = runtime_root / "hooks"
382
+ if direct.is_dir():
383
+ return direct
384
+ sibling = runtime_root.parent / "src" / "hooks"
385
+ if sibling.is_dir():
386
+ return sibling
387
+ fallback = runtime_root.parent / "hooks"
388
+ if fallback.is_dir():
389
+ return fallback
390
+ return direct
391
+
392
+
393
+ def _render_hook_command(spec: dict, *, nexo_home: Path, runtime_root: Path, hooks_dir: Path) -> str:
394
+ command_template = spec.get("command_template")
395
+ if callable(command_template):
396
+ return command_template(nexo_home, runtime_root, hooks_dir)
397
+ script_name = spec.get("script", "").strip()
398
+ script_path = hooks_dir / script_name
399
+ return (
400
+ f"NEXO_HOME={shlex.quote(str(nexo_home))} "
401
+ f"NEXO_CODE={shlex.quote(str(runtime_root))} "
402
+ f"bash {shlex.quote(str(script_path))}"
403
+ )
404
+
405
+
406
+ def _hook_identity(command: str) -> str:
407
+ text = str(command or "")
408
+ if ".session-start-ts" in text:
409
+ return "session-start-ts"
410
+ match = re.search(r"([A-Za-z0-9._-]+\.sh)\b", text)
411
+ if match:
412
+ return match.group(1)
413
+ return text.strip()
414
+
415
+
416
+ def _normalize_hook_sections(entries) -> list[dict]:
417
+ normalized: list[dict] = []
418
+ for entry in entries or []:
419
+ if not isinstance(entry, dict):
420
+ continue
421
+ hooks = entry.get("hooks")
422
+ if isinstance(hooks, list):
423
+ normalized.append(
424
+ {
425
+ "matcher": entry.get("matcher", "*") or "*",
426
+ "hooks": [dict(hook) for hook in hooks if isinstance(hook, dict)],
427
+ }
428
+ )
429
+ continue
430
+ if entry.get("command"):
431
+ hook = {"type": entry.get("type", "command"), "command": entry["command"]}
432
+ if entry.get("timeout"):
433
+ hook["timeout"] = entry["timeout"]
434
+ normalized.append({"matcher": entry.get("matcher", "*") or "*", "hooks": [hook]})
435
+ return normalized
436
+
437
+
438
+ def _merge_core_hooks(existing_hooks, *, runtime_root: Path, nexo_home: Path) -> tuple[dict, int]:
439
+ hooks_payload = dict(existing_hooks) if isinstance(existing_hooks, dict) else {}
440
+ hooks_dir = _resolve_hook_source_dir(runtime_root)
441
+ managed_count = 0
442
+
443
+ for spec in CORE_HOOK_SPECS:
444
+ event = spec["event"]
445
+ sections = _normalize_hook_sections(hooks_payload.get(event))
446
+ hooks_payload[event] = sections
447
+ command = _render_hook_command(spec, nexo_home=nexo_home, runtime_root=runtime_root, hooks_dir=hooks_dir)
448
+ identity = spec["identity"]
449
+
450
+ found = False
451
+ for section in sections:
452
+ for hook in section["hooks"]:
453
+ if _hook_identity(hook.get("command", "")) != identity:
454
+ continue
455
+ hook["type"] = "command"
456
+ hook["command"] = command
457
+ if spec.get("timeout"):
458
+ hook["timeout"] = spec["timeout"]
459
+ found = True
460
+ managed_count += 1
461
+ break
462
+ if found:
463
+ break
464
+
465
+ if found:
466
+ continue
467
+
468
+ target = None
469
+ for section in sections:
470
+ if section.get("matcher", "*") == "*":
471
+ target = section
472
+ break
473
+ if target is None:
474
+ target = {"matcher": "*", "hooks": []}
475
+ sections.append(target)
476
+
477
+ new_hook = {"type": "command", "command": command}
478
+ if spec.get("timeout"):
479
+ new_hook["timeout"] = spec["timeout"]
480
+ target["hooks"].append(new_hook)
481
+ managed_count += 1
482
+
483
+ return hooks_payload, managed_count
484
+
485
+
311
486
  def _sync_json_client(path: Path, server_config: dict, label: str, *, managed_metadata: dict | None = None) -> dict:
312
487
  payload = _load_json_object(path)
313
488
  mcp_servers = payload.setdefault("mcpServers", {})
@@ -343,6 +518,32 @@ def _claude_desktop_managed_metadata(server_config: dict, *, operator_name: str)
343
518
  }
344
519
 
345
520
 
521
+ def _sync_claude_code_settings(path: Path, server_config: dict) -> dict:
522
+ payload = _load_json_object(path)
523
+ mcp_servers = payload.setdefault("mcpServers", {})
524
+ if not isinstance(mcp_servers, dict):
525
+ mcp_servers = {}
526
+ payload["mcpServers"] = mcp_servers
527
+ action = "updated" if "nexo" in mcp_servers else "created"
528
+ mcp_servers["nexo"] = server_config
529
+
530
+ runtime_root = Path(server_config.get("env", {}).get("NEXO_CODE", "")).expanduser()
531
+ nexo_home = Path(server_config.get("env", {}).get("NEXO_HOME", "")).expanduser()
532
+ payload["hooks"], managed_hook_count = _merge_core_hooks(
533
+ payload.get("hooks", {}),
534
+ runtime_root=runtime_root,
535
+ nexo_home=nexo_home,
536
+ )
537
+ _write_json_object(path, payload)
538
+ return {
539
+ "ok": True,
540
+ "client": "claude_code",
541
+ "action": action,
542
+ "path": str(path),
543
+ "managed_hook_count": managed_hook_count,
544
+ }
545
+
546
+
346
547
  def sync_claude_code(
347
548
  *,
348
549
  nexo_home: str | os.PathLike[str] | None = None,
@@ -358,10 +559,9 @@ def sync_claude_code(
358
559
  python_path=python_path,
359
560
  operator_name=operator_name,
360
561
  )
361
- result = _sync_json_client(
562
+ result = _sync_claude_code_settings(
362
563
  _claude_code_settings_path(Path(user_home).expanduser() if user_home else None),
363
564
  server_config,
364
- "claude_code",
365
565
  )
366
566
  bootstrap_result = sync_client_bootstrap(
367
567
  "claude_code",
@@ -29,7 +29,7 @@ from cognitive._search import (
29
29
  search, bm25_search, hyde_expand_query,
30
30
  record_co_activation,
31
31
  _kg_boost_results, _apply_temporal_boost,
32
- create_trigger, check_triggers, list_triggers, delete_trigger, rearm_trigger,
32
+ create_trigger, preview_triggers, check_triggers, list_triggers, delete_trigger, rearm_trigger,
33
33
  # Constants
34
34
  CO_ACTIVATION_DECAY, CO_ACTIVATION_BOOST, CO_ACTIVATION_MIN_STRENGTH,
35
35
  )
@@ -542,18 +542,17 @@ def create_trigger(pattern: str, action: str, context: str = "") -> int:
542
542
  return cur.lastrowid
543
543
 
544
544
 
545
- def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: float = 0.7) -> list[dict]:
546
- """Check text against all armed triggers. Fires matches.
547
-
548
- Uses keyword matching by default. If use_semantic=True, also checks
549
- semantic similarity (Vestige TriggerPattern.matches pattern).
545
+ def _match_triggers(
546
+ text: str,
547
+ *,
548
+ use_semantic: bool = False,
549
+ semantic_threshold: float = 0.7,
550
+ fire: bool = False,
551
+ ) -> list[dict]:
552
+ """Match armed prospective triggers against text.
550
553
 
551
- Args:
552
- text: Input text to check
553
- use_semantic: Also do embedding similarity matching
554
- semantic_threshold: Min cosine similarity for semantic match
555
- Returns:
556
- List of fired triggers with actions
554
+ When ``fire`` is False, matches are previewed without mutating trigger state.
555
+ When ``fire`` is True, matching armed triggers transition to fired.
557
556
  """
558
557
  if not text or not text.strip():
559
558
  return []
@@ -571,7 +570,7 @@ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: fl
571
570
  if use_semantic:
572
571
  text_vec = embed(text)
573
572
 
574
- fired = []
573
+ matched_triggers = []
575
574
  now = datetime.utcnow().isoformat()
576
575
 
577
576
  for trigger in armed:
@@ -594,11 +593,12 @@ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: fl
594
593
  match_type = f"semantic({sim:.3f})"
595
594
 
596
595
  if matched:
597
- db.execute(
598
- "UPDATE prospective_triggers SET status = 'fired', fired_at = ? WHERE id = ?",
599
- (now, trigger["id"])
600
- )
601
- fired.append({
596
+ if fire:
597
+ db.execute(
598
+ "UPDATE prospective_triggers SET status = 'fired', fired_at = ? WHERE id = ?",
599
+ (now, trigger["id"])
600
+ )
601
+ matched_triggers.append({
602
602
  "id": trigger["id"],
603
603
  "pattern": trigger["trigger_pattern"],
604
604
  "action": trigger["action"],
@@ -607,10 +607,30 @@ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: fl
607
607
  "created_at": trigger["created_at"],
608
608
  })
609
609
 
610
- if fired:
610
+ if fire and matched_triggers:
611
611
  db.commit()
612
612
 
613
- return fired
613
+ return matched_triggers
614
+
615
+
616
+ def preview_triggers(text: str, use_semantic: bool = False, semantic_threshold: float = 0.7) -> list[dict]:
617
+ """Preview trigger matches without consuming or firing them."""
618
+ return _match_triggers(
619
+ text,
620
+ use_semantic=use_semantic,
621
+ semantic_threshold=semantic_threshold,
622
+ fire=False,
623
+ )
624
+
625
+
626
+ def check_triggers(text: str, use_semantic: bool = False, semantic_threshold: float = 0.7) -> list[dict]:
627
+ """Check text against all armed triggers and fire matching ones."""
628
+ return _match_triggers(
629
+ text,
630
+ use_semantic=use_semantic,
631
+ semantic_threshold=semantic_threshold,
632
+ fire=True,
633
+ )
614
634
 
615
635
 
616
636
  def list_triggers(status: str = "armed") -> list[dict]:
@@ -156,6 +156,233 @@ def _email_db():
156
156
  return conn
157
157
 
158
158
 
159
+ def _deep_sleep_dir() -> Path:
160
+ nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
161
+ return nexo_home / "operations" / "deep-sleep"
162
+
163
+
164
+ def _latest_periodic_summary(kind: str) -> dict:
165
+ root = _deep_sleep_dir()
166
+ pattern = f"*-{kind}-summary.json"
167
+ candidates = []
168
+ for path in root.glob(pattern):
169
+ try:
170
+ payload = json.loads(path.read_text(encoding="utf-8"))
171
+ except Exception:
172
+ continue
173
+ label = str(payload.get("label", "") or "")
174
+ if label:
175
+ candidates.append((label, payload))
176
+ if not candidates:
177
+ return {}
178
+ return sorted(candidates, key=lambda item: item[0])[-1][1]
179
+
180
+
181
+ def _summarize_engineering_loop(weekly: dict, monthly: dict) -> dict:
182
+ matters_now = []
183
+ for item in (weekly.get("project_pulse") or weekly.get("top_projects") or [])[:4]:
184
+ matters_now.append(
185
+ {
186
+ "title": str(item.get("project", "") or "unknown"),
187
+ "detail": f"score {item.get('score', 0)}",
188
+ "tone": str(item.get("status", "watch") or "watch"),
189
+ "meta": ", ".join(item.get("reasons", [])[:2]) if isinstance(item.get("reasons"), list) else "",
190
+ }
191
+ )
192
+
193
+ drifting = []
194
+ protocol = weekly.get("protocol_summary") or {}
195
+ for key, label in (
196
+ ("guard_check", "guard_check"),
197
+ ("heartbeat", "heartbeat"),
198
+ ("change_log", "change_log"),
199
+ ):
200
+ item = protocol.get(key) or {}
201
+ pct = item.get("compliance_pct")
202
+ if isinstance(pct, (int, float)) and pct < 70:
203
+ drifting.append(
204
+ {
205
+ "title": label,
206
+ "detail": f"{pct:.1f}% compliance",
207
+ "tone": "critical" if pct < 45 else "elevated",
208
+ "meta": "",
209
+ }
210
+ )
211
+ for item in (weekly.get("top_patterns") or [])[:3]:
212
+ pattern = str(item.get("pattern", "") or "")
213
+ if pattern:
214
+ drifting.append(
215
+ {
216
+ "title": pattern,
217
+ "detail": f"{item.get('count', 0)}x this period",
218
+ "tone": "watch",
219
+ "meta": "recurring pattern",
220
+ }
221
+ )
222
+ if len(drifting) >= 4:
223
+ break
224
+
225
+ improving = []
226
+ trend = weekly.get("trend") or {}
227
+ trust_delta = trend.get("avg_trust_delta")
228
+ if isinstance(trust_delta, (int, float)) and trust_delta > 0:
229
+ improving.append({"title": "Trust", "detail": f"{trust_delta:+.1f}", "tone": "healthy", "meta": "vs previous window"})
230
+ delivery = weekly.get("delivery_metrics") or {}
231
+ if int(delivery.get("engineering_followups", 0) or 0) > 0:
232
+ improving.append(
233
+ {
234
+ "title": "Engineering followups",
235
+ "detail": str(delivery.get("engineering_followups", 0)),
236
+ "tone": "healthy",
237
+ "meta": "guardrails created from recurring patterns",
238
+ }
239
+ )
240
+ protocol_delta = trend.get("protocol_compliance_delta")
241
+ if isinstance(protocol_delta, (int, float)) and protocol_delta > 0:
242
+ improving.append({"title": "Protocol", "detail": f"{protocol_delta:+.1f}%", "tone": "healthy", "meta": "vs previous window"})
243
+ duplicate_followup_delta = trend.get("followup_duplicate_open_delta")
244
+ if isinstance(duplicate_followup_delta, int) and duplicate_followup_delta < 0:
245
+ improving.append({"title": "Followup duplication", "detail": f"{duplicate_followup_delta:+d}", "tone": "healthy", "meta": "open duplicates"})
246
+ learning_noise_delta = trend.get("learning_noise_delta")
247
+ if isinstance(learning_noise_delta, int) and learning_noise_delta < 0:
248
+ improving.append({"title": "Learning noise", "detail": f"{learning_noise_delta:+d}", "tone": "healthy", "meta": "active noise pressure"})
249
+ corrections_delta = trend.get("total_corrections_delta")
250
+ if isinstance(corrections_delta, int) and corrections_delta < 0:
251
+ improving.append({"title": "Corrections", "detail": f"{corrections_delta:+d}", "tone": "healthy", "meta": "lower is better"})
252
+ mood_delta = trend.get("avg_mood_delta")
253
+ if isinstance(mood_delta, (int, float)) and mood_delta > 0:
254
+ improving.append({"title": "Mood", "detail": f"{mood_delta:+.3f}", "tone": "healthy", "meta": "vs previous window"})
255
+
256
+ duplicate_followup_rate_delta = trend.get("followup_duplicate_rate_delta")
257
+ if isinstance(duplicate_followup_rate_delta, (int, float)) and duplicate_followup_rate_delta > 0:
258
+ drifting.append({"title": "followup_duplicates", "detail": f"{duplicate_followup_rate_delta:+.1f}%", "tone": "critical" if duplicate_followup_rate_delta >= 5 else "watch", "meta": "open duplicate rate"})
259
+ learning_noise_rate_delta = trend.get("learning_noise_rate_delta")
260
+ if isinstance(learning_noise_rate_delta, (int, float)) and learning_noise_rate_delta > 0:
261
+ drifting.append({"title": "learning_noise", "detail": f"{learning_noise_rate_delta:+.1f}%", "tone": "critical" if learning_noise_rate_delta >= 5 else "watch", "meta": "active noise rate"})
262
+
263
+ return {
264
+ "weekly": weekly,
265
+ "monthly": monthly,
266
+ "matters_now": matters_now[:4],
267
+ "drifting": drifting[:4],
268
+ "improving": improving[:4],
269
+ }
270
+
271
+
272
+ def _safe_json(value, default):
273
+ if value in (None, ""):
274
+ return default
275
+ if isinstance(value, (list, dict)):
276
+ return value
277
+ try:
278
+ return json.loads(value)
279
+ except Exception:
280
+ return default
281
+
282
+
283
+ def _protocol_explainability_snapshot(limit: int = 20) -> dict:
284
+ db = _db()
285
+ conn = db.get_db()
286
+ max_limit = max(5, min(int(limit or 20), 100))
287
+
288
+ protocol_summary = db.protocol_compliance_summary(7)
289
+ recent_tasks = []
290
+ for row in conn.execute(
291
+ """SELECT * FROM protocol_tasks
292
+ ORDER BY opened_at DESC
293
+ LIMIT ?""",
294
+ (max_limit,),
295
+ ).fetchall():
296
+ item = dict(row)
297
+ for field in (
298
+ "files",
299
+ "plan",
300
+ "known_facts",
301
+ "unknowns",
302
+ "constraints",
303
+ "evidence_refs",
304
+ "response_reasons",
305
+ ):
306
+ item[field] = _safe_json(item.get(field), [])
307
+ item["has_evidence"] = bool(str(item.get("close_evidence") or "").strip())
308
+ item["guarded_open"] = bool(item.get("opened_with_guard") or item.get("opened_with_rules"))
309
+ recent_tasks.append(item)
310
+
311
+ recent_debts = [dict(row) for row in conn.execute(
312
+ """SELECT * FROM protocol_debt
313
+ ORDER BY created_at DESC
314
+ LIMIT ?""",
315
+ (max_limit,),
316
+ ).fetchall()]
317
+
318
+ debt_summary = {"open_total": 0, "by_severity": {}, "by_type": {}}
319
+ for debt in recent_debts:
320
+ if debt.get("status") != "open":
321
+ continue
322
+ debt_summary["open_total"] += 1
323
+ severity = str(debt.get("severity") or "warn")
324
+ debt_type = str(debt.get("debt_type") or "unknown")
325
+ debt_summary["by_severity"][severity] = debt_summary["by_severity"].get(severity, 0) + 1
326
+ debt_summary["by_type"][debt_type] = debt_summary["by_type"].get(debt_type, 0) + 1
327
+
328
+ recent_runs = db.list_workflow_runs(include_closed=True, limit=max_limit)
329
+ workflow_summary = {
330
+ "total": len(recent_runs),
331
+ "open_runs": sum(1 for run in recent_runs if run.get("status") not in {"completed", "failed", "cancelled"}),
332
+ "blocked_runs": sum(1 for run in recent_runs if run.get("status") == "blocked"),
333
+ "waiting_approval": sum(1 for run in recent_runs if run.get("status") == "waiting_approval"),
334
+ }
335
+
336
+ recent_goals = db.list_workflow_goals(include_closed=True, limit=max_limit)
337
+ goal_summary = {
338
+ "total": len(recent_goals),
339
+ "active": sum(1 for goal in recent_goals if goal.get("status") == "active"),
340
+ "blocked": sum(1 for goal in recent_goals if goal.get("status") == "blocked"),
341
+ "closed": sum(1 for goal in recent_goals if goal.get("status") in {"completed", "cancelled", "abandoned"}),
342
+ }
343
+
344
+ guard_checks = [dict(row) for row in conn.execute(
345
+ """SELECT area, files, learnings_returned, blocking_rules_returned, created_at
346
+ FROM guard_checks
347
+ ORDER BY created_at DESC
348
+ LIMIT ?""",
349
+ (max_limit,),
350
+ ).fetchall()]
351
+ areas = {}
352
+ blocking_hits = 0
353
+ for check in guard_checks:
354
+ area = str(check.get("area") or "unknown")
355
+ areas[area] = areas.get(area, 0) + 1
356
+ blocking_hits += int(check.get("blocking_rules_returned") or 0)
357
+
358
+ conditioned_learnings = [dict(row) for row in conn.execute(
359
+ """SELECT id, title, applies_to, priority, status, weight, guard_hits, updated_at
360
+ FROM learnings
361
+ WHERE status = 'active' AND applies_to IS NOT NULL AND TRIM(applies_to) != ''
362
+ ORDER BY COALESCE(guard_hits, 0) DESC, updated_at DESC
363
+ LIMIT ?""",
364
+ (max_limit,),
365
+ ).fetchall()]
366
+
367
+ return {
368
+ "protocol_summary": protocol_summary,
369
+ "debt_summary": debt_summary,
370
+ "recent_tasks": recent_tasks,
371
+ "recent_debts": recent_debts,
372
+ "workflow_summary": workflow_summary,
373
+ "recent_runs": recent_runs,
374
+ "goal_summary": goal_summary,
375
+ "recent_goals": recent_goals,
376
+ "guard_summary": {
377
+ "recent_checks": len(guard_checks),
378
+ "blocking_hits": blocking_hits,
379
+ "areas": areas,
380
+ },
381
+ "guard_checks": guard_checks,
382
+ "conditioned_learnings": conditioned_learnings,
383
+ }
384
+
385
+
159
386
  # ---------------------------------------------------------------------------
160
387
  # HTML page routes — Jinja2 with fallback to plain file
161
388
  # ---------------------------------------------------------------------------
@@ -221,6 +448,10 @@ async def page_trust():
221
448
  async def page_guard():
222
449
  return _render("guard.html")
223
450
 
451
+ @app.get("/protocol", response_class=HTMLResponse)
452
+ async def page_protocol():
453
+ return _render("protocol.html", snapshot=_protocol_explainability_snapshot())
454
+
224
455
  @app.get("/cortex", response_class=HTMLResponse)
225
456
  async def page_cortex():
226
457
  return _render("cortex.html")
@@ -396,6 +627,30 @@ async def api_trust():
396
627
  }
397
628
 
398
629
 
630
+ @app.get("/api/project-pulse")
631
+ async def api_project_pulse(kind: str = Query("weekly", pattern="^(weekly|monthly)$")):
632
+ """Latest project pressure snapshot from Deep Sleep summaries."""
633
+ summary = _latest_periodic_summary(kind)
634
+ if not summary:
635
+ return JSONResponse({"error": f"No {kind} summary found"}, status_code=404)
636
+ return {
637
+ "kind": kind,
638
+ "label": summary.get("label"),
639
+ "project_pulse": summary.get("project_pulse", []),
640
+ "top_projects": summary.get("top_projects", []),
641
+ }
642
+
643
+
644
+ @app.get("/api/engineering-loop")
645
+ async def api_engineering_loop():
646
+ """Dashboard narrative: what matters now, what is drifting, what is improving."""
647
+ weekly = _latest_periodic_summary("weekly")
648
+ monthly = _latest_periodic_summary("monthly")
649
+ if not weekly and not monthly:
650
+ return JSONResponse({"error": "No periodic Deep Sleep summaries found"}, status_code=404)
651
+ return _summarize_engineering_loop(weekly or {}, monthly or {})
652
+
653
+
399
654
  @app.get("/api/adaptive")
400
655
  async def api_adaptive():
401
656
  """Adaptive personality: current weight state + mode history."""
@@ -1173,6 +1428,15 @@ async def api_trust_events(limit: int = Query(50, ge=1, le=200)):
1173
1428
  return {"events": [dict(r) for r in rows]}
1174
1429
 
1175
1430
 
1431
+ # ---------------------------------------------------------------------------
1432
+ # Protocol Explainability
1433
+ # ---------------------------------------------------------------------------
1434
+
1435
+ @app.get("/api/protocol")
1436
+ async def api_protocol(limit: int = Query(20, ge=5, le=100)):
1437
+ return _protocol_explainability_snapshot(limit=limit)
1438
+
1439
+
1176
1440
  # ---------------------------------------------------------------------------
1177
1441
  # Guard Heatmap
1178
1442
  # ---------------------------------------------------------------------------
@@ -162,6 +162,10 @@
162
162
  <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
163
163
  Guard Heatmap
164
164
  </a>
165
+ <a href="/protocol" class="nav-item flex items-center gap-2.5 px-3 py-1.5 rounded-md text-xs text-slate-400 transition-colors" data-page="protocol">
166
+ <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z"/></svg>
167
+ Protocol Explainability
168
+ </a>
165
169
  <a href="/cortex" class="nav-item flex items-center gap-2.5 px-3 py-1.5 rounded-md text-xs text-slate-400 transition-colors" data-page="cortex">
166
170
  <svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
167
171
  Cortex Monitor