nexo-brain 2.6.21 → 2.7.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.21",
3
+ "version": "2.7.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -38,18 +38,15 @@ That means NEXO now manages not only the shared runtime and MCP wiring, but also
38
38
  - For Codex specifically, `nexo chat` and Codex headless automation inject the current bootstrap explicitly, so Codex starts as NEXO even when plain global Codex startup is inconsistent about global instructions.
39
39
  - Deep Sleep now reads both Claude Code and Codex transcript stores, so overnight analysis still works even when the user spends the day in Codex.
40
40
 
41
- Version `2.6.14` closes those parity gaps in practice, `2.6.15` hardens the installed-runtime migration path so existing users actually receive the managed bootstrap updates cleanly, `2.6.16` pushes the system further in three directions, `2.6.17` finishes the annoying last-mile migration bugs for real existing installs, `2.6.18` tightens the remaining practical gaps around manual Codex use, Deep Sleep horizon artifacts, and retrieval honesty, `2.6.20` makes the recommended Claude profile explicit across installer, runtime defaults, existing installs, and the update path itself with `Opus 4.6 with 1M context`, and `2.6.21` upgrades Deep Sleep from passive nightly analysis toward concrete engineering action.
42
-
43
- - Codex now gets managed global bootstrap/model sync in `~/.codex/config.toml`, so sessions opened outside `nexo chat` are much less likely to start as plain Codex.
44
- - Codex config now also persists a managed `mcp_servers.nexo` entry, so the shared brain survives even if ad-hoc Codex MCP state drifts.
45
- - Runtime doctor now audits recent Codex sessions for real startup discipline and verifies Claude Desktop shared-brain metadata explicitly instead of treating both as invisible best-effort wiring.
46
- - Retrieval is smarter by default: HyDE and spreading activation now auto-enable when the query shape benefits, while exact lookups remain conservative.
47
- - Retrieval explanations now surface confidence and the auto-strategy that fired, while associative expansion trims itself back to `top_k` instead of leaking low-signal neighbors.
48
- - Deep Sleep now blends recent context with older context over a 60-day horizon, and memory decay now tracks per-memory `stability` and `difficulty` instead of relying only on global decay constants.
49
- - Deep Sleep now also carries project-priority weighting into its long-horizon context and writes reusable weekly/monthly summary artifacts instead of reasoning only day by day.
50
- - Deep Sleep now semantically deduplicates followups, consolidates overlapping learnings, flags contradictory learnings for review, and backfills explicit engineering followups when recurring patterns imply a concrete fix.
51
- - Existing installs that already had NEXO connected to Codex now backfill that client state automatically during update/sync, so the managed Codex bootstrap actually lands without manual cleanup.
52
- - Bootstrap docs now fall back to the operator name `NEXO` when local metadata is blank, avoiding broken headings in `CLAUDE.md` and `AGENTS.md`.
41
+ Versions `2.6.14` through `2.6.21` established the practical shared-brain baseline: managed Claude/Codex bootstrap, Codex config sync, transcript-aware Deep Sleep, 60-day long-horizon analysis, weekly/monthly summary artifacts, retrieval auto-mode, and the first Deep Sleep engineering loop.
42
+
43
+ Version `2.7.0` closes the next operational gap:
44
+
45
+ - Weekly/monthly Deep Sleep summaries now include protocol compliance, engineering-loop output, project pulse, and trend-vs-previous-period data.
46
+ - Runtime doctor now audits both weekly protocol compliance and release-artifact sync drift instead of leaving those checks implicit.
47
+ - The repo now ships `scripts/verify_release_readiness.py`, and tagged publish runs it automatically so release discipline is enforced in the product itself.
48
+ - The dashboard now surfaces `What Matters Now`, `What Is Drifting`, and `What Is Improving` directly from the periodic Deep Sleep summaries.
49
+ - The unreleased Codex launcher fixes after `v2.6.21` are now included: stronger `nexo chat` client selection, corrected launch mode handling, tracked last terminal choice, and aligned interactive flags.
53
50
 
54
51
  ### Client Capability Matrix
55
52
 
@@ -523,7 +520,7 @@ npx nexo-brain # detects current version, migrates automatically
523
520
 
524
521
  NEXO Brain includes a local CLI that runs independently of any single terminal client:
525
522
 
526
- - `nexo chat` — launch the configured terminal client with NEXO as the operator
523
+ - `nexo chat` — launch a NEXO terminal client; if both Claude Code and Codex are available, it asks every time which one to open and puts the last-used client first
527
524
  - `nexo update` — sync runtime from source, run migrations, reconcile schedules
528
525
  - `nexo doctor --tier runtime` — boot/runtime/deep diagnostics with `--fix` mode
529
526
  - `nexo scripts list` — list all personal scripts and their status
@@ -636,20 +633,20 @@ The installer handles everything and syncs the same `nexo` MCP brain into Claude
636
633
  After install, use the runtime CLI:
637
634
 
638
635
  ```bash
639
- nexo chat # Launch the configured terminal client (Claude Code or Codex)
636
+ nexo chat # Launch a NEXO terminal client (asks if both Claude Code and Codex are available)
640
637
  nexo doctor # Check runtime health
641
638
  nexo update # Pull latest version and sync
642
639
  nexo clients sync # Re-sync Claude Code/Desktop/Codex to the same brain
643
640
  nexo scripts list # See your personal scripts
644
641
  ```
645
642
 
646
- During install, NEXO now asks which interactive clients you want to connect, which one `nexo chat` should open by default, whether to enable background automation, which backend should run that automation, and which model profile each active terminal/backend should use. Shared brain stays on in every mode.
643
+ During install, NEXO now asks which interactive clients you want to connect, which one `nexo chat` should suggest first when multiple terminal clients are available, whether to enable background automation, which backend should run that automation, and which model profile each active terminal/backend should use. Shared brain stays on in every mode.
647
644
 
648
645
  Recommended defaults:
649
646
  - Claude Code: `Opus 4.6 with 1M context`
650
647
  - Codex: `gpt-5.4` with `xhigh` reasoning
651
648
 
652
- Or use the shell alias created during install (e.g. `atlas`), which now runs `nexo chat .` so it opens whichever terminal client you selected as default.
649
+ Or use the shell alias created during install (e.g. `atlas`), which now runs `nexo chat .` so it opens the terminal client you pick for that session, with the last-used option shown first.
653
650
 
654
651
  Your operator will greet you immediately — adapted to the time of day, resuming from where you left off. No cold starts.
655
652
 
@@ -665,6 +662,7 @@ The project still recommends Claude Code as the primary path, but contributions
665
662
 
666
663
  Maintainers and contributors touching startup, bootstrap, Deep Sleep, or shared-brain behavior should also use the client parity checklist:
667
664
  - [docs/client-parity-checklist.md](docs/client-parity-checklist.md)
665
+ - `python3 scripts/verify_release_readiness.py`
668
666
 
669
667
  ### What Gets Installed
670
668
 
@@ -842,7 +840,7 @@ When Claude Desktop is installed, `nexo-brain`, `nexo update`, and `nexo clients
842
840
 
843
841
  ### Codex
844
842
 
845
- When Codex CLI is available, `nexo-brain`, `nexo update`, and `nexo clients sync` register the same `nexo` MCP server via `codex mcp add`, so Codex uses the same local memory store as Claude Code and Claude Desktop. If selected during install, `nexo chat` can open Codex directly and background automation can also run through Codex. The current recommended Codex profile is `gpt-5.4` with `xhigh` reasoning.
843
+ When Codex CLI is available, `nexo-brain`, `nexo update`, and `nexo clients sync` register the same `nexo` MCP server via `codex mcp add`, so Codex uses the same local memory store as Claude Code and Claude Desktop. If selected during install, `nexo chat` can open Codex directly and background automation can also run through Codex. Interactive `nexo chat` launches use Codex's aggressive no-confirmation mode so the session does not stall on repetitive approval prompts. The current recommended Codex profile is `gpt-5.4` with `xhigh` reasoning.
846
844
 
847
845
  ### OpenClaw
848
846
 
@@ -956,7 +954,7 @@ If NEXO Brain is useful to you, consider:
956
954
  - **Personal scripts registry**: Scripts in `NEXO_HOME/scripts/` tracked in SQLite with metadata, categories, schedules. Full lifecycle: create, sync, reconcile, schedule, unschedule, remove.
957
955
  - **Orchestrator removed from core** (breaking): Was opt-in personal automation adding complexity for all users. Existing users keep their setup in `NEXO_HOME/scripts/`.
958
956
  - **Claude Code plugin structure**: `plugin.json`, entry point, packaging for marketplace submission.
959
- - **`nexo chat`**: Official command to launch the configured terminal client with NEXO as operator.
957
+ - **`nexo chat`**: Official command to launch a NEXO terminal client, asking when multiple supported terminal clients are available.
960
958
  - **Managed Evolution hardening**: Can modify core behavior modules with rollback followups.
961
959
  - Cron recovery hardened: TCC diagnostics, keepalive sync, personal schedule catchup.
962
960
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.21",
3
+ "version": "2.7.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -109,6 +109,10 @@ def _codex_initial_messages_config(prompt_text: str) -> str:
109
109
  return f'initial_messages=[{{role="system",content={json.dumps(prompt_text, ensure_ascii=False)}}}]'
110
110
 
111
111
 
112
+ def _codex_interactive_launch_flags() -> list[str]:
113
+ return ["--sandbox", "danger-full-access", "--ask-for-approval", "never"]
114
+
115
+
112
116
  def build_interactive_client_command(
113
117
  *,
114
118
  target: str | os.PathLike[str],
@@ -140,7 +144,7 @@ def build_interactive_client_command(
140
144
  raise TerminalClientUnavailableError(
141
145
  "Codex launcher not found in PATH. Install `codex` first or reconfigure NEXO."
142
146
  )
143
- cmd = [codex_bin]
147
+ cmd = [codex_bin, *_codex_interactive_launch_flags()]
144
148
  bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
145
149
  if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
146
150
  cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
@@ -201,7 +205,7 @@ def build_followup_terminal_shell_command(
201
205
  "Codex launcher not found in PATH. Install `codex` first or reconfigure NEXO."
202
206
  )
203
207
  target_cwd = str(Path(cwd).expanduser()) if cwd else str(Path.home())
204
- cmd = [codex_bin]
208
+ cmd = [codex_bin, *_codex_interactive_launch_flags()]
205
209
  bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
206
210
  if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
207
211
  cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
package/src/cli.py CHANGED
@@ -41,6 +41,11 @@ from pathlib import Path
41
41
 
42
42
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
43
43
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
44
+ TERMINAL_CLIENT_LABELS = {
45
+ "claude_code": "Claude Code",
46
+ "codex": "Codex",
47
+ }
48
+ TERMINAL_CLIENT_ORDER = ("claude_code", "codex")
44
49
 
45
50
 
46
51
  def _get_version() -> str:
@@ -844,8 +849,73 @@ def _dashboard(args):
844
849
  return _service_control("dashboard", args.action)
845
850
 
846
851
 
852
+ def _terminal_client_label(client: str) -> str:
853
+ return TERMINAL_CLIENT_LABELS.get(client, client.replace("_", " ").title())
854
+
855
+
856
+ def _ordered_available_terminal_clients(preferences: dict, detected: dict) -> list[str]:
857
+ enabled = preferences.get("interactive_clients", {})
858
+ last_used = str(preferences.get("last_terminal_client", "")).strip()
859
+ preferred = str(preferences.get("default_terminal_client", "")).strip()
860
+ ordered: list[str] = []
861
+
862
+ for client in (last_used, preferred, *TERMINAL_CLIENT_ORDER):
863
+ if client in TERMINAL_CLIENT_ORDER and client not in ordered:
864
+ ordered.append(client)
865
+
866
+ return [
867
+ client
868
+ for client in ordered
869
+ if enabled.get(client, False) and detected.get(client, {}).get("installed", False)
870
+ ]
871
+
872
+
873
+ def _preferred_terminal_client_label(preferences: dict, clients: list[str]) -> str:
874
+ last_used = str(preferences.get("last_terminal_client", "")).strip()
875
+ if clients and clients[0] == last_used:
876
+ return "last choice"
877
+ return "default"
878
+
879
+
880
+ def _prompt_for_terminal_client(
881
+ clients: list[str],
882
+ normalize_client_key,
883
+ *,
884
+ preferred_label: str = "default",
885
+ ) -> str | None:
886
+ if not clients:
887
+ return None
888
+ if len(clients) == 1:
889
+ return clients[0]
890
+
891
+ while True:
892
+ print("Select terminal client for this chat:")
893
+ for index, client in enumerate(clients, start=1):
894
+ suffix = f" [{preferred_label}]" if index == 1 else ""
895
+ print(f" {index}. {_terminal_client_label(client)}{suffix}")
896
+
897
+ try:
898
+ response = input(f"Choose 1-{len(clients)} [1]: ").strip()
899
+ except EOFError:
900
+ return clients[0]
901
+
902
+ if not response:
903
+ return clients[0]
904
+ if response.isdigit():
905
+ choice = int(response)
906
+ if 1 <= choice <= len(clients):
907
+ return clients[choice - 1]
908
+
909
+ client_key = normalize_client_key(response)
910
+ if client_key in clients:
911
+ return client_key
912
+
913
+ print("Invalid choice. Try again.", file=sys.stderr)
914
+
915
+
847
916
  def _chat(args):
848
917
  target = args.path or "."
918
+ selected_client = getattr(args, "client", None)
849
919
 
850
920
  try:
851
921
  from auto_update import startup_preflight
@@ -867,20 +937,44 @@ def _chat(args):
867
937
  pass
868
938
 
869
939
  try:
940
+ from client_preferences import (
941
+ detect_installed_clients,
942
+ load_client_preferences,
943
+ normalize_client_key,
944
+ save_client_preferences,
945
+ )
870
946
  from agent_runner import TerminalClientUnavailableError, launch_interactive_client
871
947
  except ImportError:
872
948
  print("Agent runner module not found. Ensure NEXO is properly installed.", file=sys.stderr)
873
949
  return 1
874
950
 
951
+ if not selected_client:
952
+ try:
953
+ preferences = load_client_preferences()
954
+ detected = detect_installed_clients()
955
+ clients = _ordered_available_terminal_clients(preferences, detected)
956
+ selected_client = _prompt_for_terminal_client(
957
+ clients,
958
+ normalize_client_key,
959
+ preferred_label=_preferred_terminal_client_label(preferences, clients),
960
+ )
961
+ except Exception:
962
+ selected_client = None
963
+
875
964
  try:
876
965
  result = launch_interactive_client(
877
966
  target=target,
878
- client=getattr(args, "client", None),
967
+ client=selected_client,
879
968
  env=os.environ.copy(),
880
969
  )
881
970
  except TerminalClientUnavailableError as exc:
882
971
  print(str(exc), file=sys.stderr)
883
972
  return 1
973
+ if result.returncode == 0 and selected_client:
974
+ try:
975
+ save_client_preferences(last_terminal_client=normalize_client_key(selected_client))
976
+ except Exception:
977
+ pass
884
978
  return int(result.returncode)
885
979
 
886
980
 
@@ -1014,7 +1108,7 @@ def _print_help():
1014
1108
  print(f"""NEXO Runtime CLI v{v}
1015
1109
 
1016
1110
  Commands:
1017
- nexo chat [path] [--client claude_code|codex] Launch the selected terminal client
1111
+ nexo chat [path] [--client claude_code|codex] Launch a NEXO terminal client
1018
1112
  nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
1019
1113
  nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|run|doctor|call|unschedule|remove
1020
1114
  Personal scripts
@@ -1036,12 +1130,12 @@ def main():
1036
1130
  sub = parser.add_subparsers(dest="command")
1037
1131
 
1038
1132
  # -- chat --
1039
- chat_parser = sub.add_parser("chat", help="Launch the selected terminal client")
1133
+ chat_parser = sub.add_parser("chat", help="Launch a NEXO terminal client")
1040
1134
  chat_parser.add_argument("path", nargs="?", default=".", help="Working directory (default: current directory)")
1041
1135
  chat_parser.add_argument(
1042
1136
  "--client",
1043
1137
  choices=["claude_code", "codex"],
1044
- help="Override the configured default terminal client",
1138
+ help="Override the chat picker and launch a specific terminal client",
1045
1139
  )
1046
1140
 
1047
1141
  # -- scripts --
@@ -73,6 +73,7 @@ def default_client_preferences() -> dict:
73
73
  CLIENT_CLAUDE_DESKTOP: False,
74
74
  },
75
75
  "default_terminal_client": CLIENT_CLAUDE_CODE,
76
+ "last_terminal_client": "",
76
77
  "automation_enabled": True,
77
78
  "automation_backend": CLIENT_CLAUDE_CODE,
78
79
  "client_runtime_profiles": default_client_runtime_profiles(),
@@ -189,6 +190,14 @@ def normalize_default_terminal_client(value, interactive_clients: dict[str, bool
189
190
  return CLIENT_CLAUDE_CODE
190
191
 
191
192
 
193
+ def normalize_last_terminal_client(value, interactive_clients: dict[str, bool] | None = None) -> str:
194
+ interactive_clients = normalize_interactive_clients(interactive_clients or {})
195
+ candidate = normalize_client_key(value)
196
+ if candidate in TERMINAL_CLIENT_KEYS and interactive_clients.get(candidate, False):
197
+ return candidate
198
+ return ""
199
+
200
+
192
201
  def normalize_automation_enabled(value) -> bool:
193
202
  return _coerce_bool(value, True)
194
203
 
@@ -282,6 +291,10 @@ def normalize_client_preferences(
282
291
  schedule.get("default_terminal_client"),
283
292
  interactive_clients=interactive_clients,
284
293
  )
294
+ last_terminal_client = normalize_last_terminal_client(
295
+ schedule.get("last_terminal_client"),
296
+ interactive_clients=interactive_clients,
297
+ )
285
298
  automation_backend = normalize_automation_backend(
286
299
  schedule.get("automation_backend"),
287
300
  automation_enabled=automation_enabled,
@@ -295,6 +308,7 @@ def normalize_client_preferences(
295
308
  return {
296
309
  "interactive_clients": interactive_clients,
297
310
  "default_terminal_client": default_terminal_client,
311
+ "last_terminal_client": last_terminal_client,
298
312
  "automation_enabled": automation_enabled,
299
313
  "automation_backend": automation_backend,
300
314
  "client_runtime_profiles": runtime_profiles,
@@ -307,6 +321,7 @@ def apply_client_preferences(
307
321
  *,
308
322
  interactive_clients: dict | None = None,
309
323
  default_terminal_client: str | None = None,
324
+ last_terminal_client: str | None = None,
310
325
  automation_enabled=None,
311
326
  automation_backend: str | None = None,
312
327
  client_runtime_profiles: dict | None = None,
@@ -324,6 +339,10 @@ def apply_client_preferences(
324
339
  default_terminal_client if default_terminal_client is not None else current["default_terminal_client"],
325
340
  interactive_clients=merged["interactive_clients"],
326
341
  )
342
+ merged["last_terminal_client"] = normalize_last_terminal_client(
343
+ last_terminal_client if last_terminal_client is not None else current.get("last_terminal_client", ""),
344
+ interactive_clients=merged["interactive_clients"],
345
+ )
327
346
  merged["automation_backend"] = normalize_automation_backend(
328
347
  automation_backend if automation_backend is not None else current["automation_backend"],
329
348
  automation_enabled=merged["automation_enabled"],
@@ -349,6 +368,7 @@ def save_client_preferences(
349
368
  *,
350
369
  interactive_clients: dict | None = None,
351
370
  default_terminal_client: str | None = None,
371
+ last_terminal_client: str | None = None,
352
372
  automation_enabled=None,
353
373
  automation_backend: str | None = None,
354
374
  client_runtime_profiles: dict | None = None,
@@ -358,6 +378,7 @@ def save_client_preferences(
358
378
  load_schedule_config(),
359
379
  interactive_clients=interactive_clients,
360
380
  default_terminal_client=default_terminal_client,
381
+ last_terminal_client=last_terminal_client,
361
382
  automation_enabled=automation_enabled,
362
383
  automation_backend=automation_backend,
363
384
  client_runtime_profiles=client_runtime_profiles,
@@ -156,6 +156,106 @@ 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
+ corrections_delta = trend.get("total_corrections_delta")
244
+ if isinstance(corrections_delta, int) and corrections_delta < 0:
245
+ improving.append({"title": "Corrections", "detail": f"{corrections_delta:+d}", "tone": "healthy", "meta": "lower is better"})
246
+ mood_delta = trend.get("avg_mood_delta")
247
+ if isinstance(mood_delta, (int, float)) and mood_delta > 0:
248
+ improving.append({"title": "Mood", "detail": f"{mood_delta:+.3f}", "tone": "healthy", "meta": "vs previous window"})
249
+
250
+ return {
251
+ "weekly": weekly,
252
+ "monthly": monthly,
253
+ "matters_now": matters_now[:4],
254
+ "drifting": drifting[:4],
255
+ "improving": improving[:4],
256
+ }
257
+
258
+
159
259
  # ---------------------------------------------------------------------------
160
260
  # HTML page routes — Jinja2 with fallback to plain file
161
261
  # ---------------------------------------------------------------------------
@@ -396,6 +496,30 @@ async def api_trust():
396
496
  }
397
497
 
398
498
 
499
+ @app.get("/api/project-pulse")
500
+ async def api_project_pulse(kind: str = Query("weekly", pattern="^(weekly|monthly)$")):
501
+ """Latest project pressure snapshot from Deep Sleep summaries."""
502
+ summary = _latest_periodic_summary(kind)
503
+ if not summary:
504
+ return JSONResponse({"error": f"No {kind} summary found"}, status_code=404)
505
+ return {
506
+ "kind": kind,
507
+ "label": summary.get("label"),
508
+ "project_pulse": summary.get("project_pulse", []),
509
+ "top_projects": summary.get("top_projects", []),
510
+ }
511
+
512
+
513
+ @app.get("/api/engineering-loop")
514
+ async def api_engineering_loop():
515
+ """Dashboard narrative: what matters now, what is drifting, what is improving."""
516
+ weekly = _latest_periodic_summary("weekly")
517
+ monthly = _latest_periodic_summary("monthly")
518
+ if not weekly and not monthly:
519
+ return JSONResponse({"error": "No periodic Deep Sleep summaries found"}, status_code=404)
520
+ return _summarize_engineering_loop(weekly or {}, monthly or {})
521
+
522
+
399
523
  @app.get("/api/adaptive")
400
524
  async def api_adaptive():
401
525
  """Adaptive personality: current weight state + mode history."""
@@ -138,6 +138,30 @@
138
138
  </div>
139
139
  </div>
140
140
  </div>
141
+
142
+ <!-- Row 3: engineering loop narrative -->
143
+ <div class="grid grid-cols-3 gap-4">
144
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
145
+ <div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-3">What Matters Now</div>
146
+ <ul id="matters-now-list" class="space-y-2">
147
+ <li class="text-xs text-slate-600 py-1">loading...</li>
148
+ </ul>
149
+ </div>
150
+
151
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
152
+ <div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-3">What Is Drifting</div>
153
+ <ul id="drifting-list" class="space-y-2">
154
+ <li class="text-xs text-slate-600 py-1">loading...</li>
155
+ </ul>
156
+ </div>
157
+
158
+ <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
159
+ <div class="text-xs uppercase tracking-wider text-slate-400 font-medium mb-3">What Is Improving</div>
160
+ <ul id="improving-list" class="space-y-2">
161
+ <li class="text-xs text-slate-600 py-1">loading...</li>
162
+ </ul>
163
+ </div>
164
+ </div>
141
165
  </div>
142
166
 
143
167
  <!-- Quick Create Modal -->
@@ -244,7 +268,7 @@ async function submitQuickCreate(e) {
244
268
  async function loadDashboardData() {
245
269
  const today = getToday();
246
270
 
247
- const [trustData, statsData, remindersData, followupsData, sessionsData, watchdogData, inboxData] =
271
+ const [trustData, statsData, remindersData, followupsData, sessionsData, watchdogData, inboxData, engineeringData] =
248
272
  await Promise.all([
249
273
  fetchJSON('/api/trust'),
250
274
  fetchJSON('/api/stats'),
@@ -253,6 +277,7 @@ async function loadDashboardData() {
253
277
  fetchJSON('/api/sessions?limit=3'),
254
278
  fetchJSON('/api/watchdog'),
255
279
  fetchJSON('/api/inbox/unread'),
280
+ fetchJSON('/api/engineering-loop'),
256
281
  ]);
257
282
 
258
283
  // --- Trust Score (animated gauge) ---
@@ -426,6 +451,39 @@ async function loadDashboardData() {
426
451
  badge.classList.add('flex');
427
452
  }
428
453
  }
454
+
455
+ // --- Engineering loop narrative ---
456
+ const toneClass = tone => {
457
+ if (tone === 'critical') return 'text-red-400';
458
+ if (tone === 'elevated' || tone === 'watch') return 'text-amber-400';
459
+ return 'text-emerald-400';
460
+ };
461
+ const renderNarrativeList = (id, items, emptyText) => {
462
+ const node = document.getElementById(id);
463
+ if (!node) return;
464
+ if (!items || items.length === 0) {
465
+ node.innerHTML = `<li class="text-xs text-slate-600 py-1">${escapeHtml(emptyText)}</li>`;
466
+ return;
467
+ }
468
+ node.innerHTML = items.map(item => `
469
+ <li class="border-b border-slate-800/30 pb-2 last:border-0 last:pb-0">
470
+ <div class="flex items-center justify-between gap-2">
471
+ <span class="text-xs text-slate-300">${escapeHtml(item.title || '--')}</span>
472
+ <span class="text-[10px] font-mono ${toneClass(item.tone)}">${escapeHtml(item.detail || '')}</span>
473
+ </div>
474
+ ${item.meta ? `<div class="text-[10px] text-slate-600 mt-1">${escapeHtml(item.meta)}</div>` : ''}
475
+ </li>
476
+ `).join('');
477
+ };
478
+ if (engineeringData && !engineeringData.error) {
479
+ renderNarrativeList('matters-now-list', engineeringData.matters_now, 'No active pressure detected');
480
+ renderNarrativeList('drifting-list', engineeringData.drifting, 'No major drift detected');
481
+ renderNarrativeList('improving-list', engineeringData.improving, 'No improvement deltas yet');
482
+ } else {
483
+ renderNarrativeList('matters-now-list', [], 'No periodic summary available');
484
+ renderNarrativeList('drifting-list', [], 'No periodic summary available');
485
+ renderNarrativeList('improving-list', [], 'No periodic summary available');
486
+ }
429
487
  }
430
488
 
431
489
  loadDashboardData();
@@ -6,6 +6,7 @@ import json
6
6
  import os
7
7
  import platform
8
8
  import plistlib
9
+ import re
9
10
  import subprocess
10
11
  import sys
11
12
  import time
@@ -38,6 +39,8 @@ SPECIAL_LAUNCHAGENT_IDS = {"prevent-sleep", "tcc-approve"}
38
39
  SPECIAL_ENV_NORMALIZE_IDS = SPECIAL_LAUNCHAGENT_IDS
39
40
  OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
40
41
  SCHEDULE_FILE = NEXO_HOME / "config" / "schedule.json"
42
+ PACKAGE_JSON = NEXO_CODE / "package.json"
43
+ CHANGELOG_FILE = NEXO_CODE / "CHANGELOG.md"
41
44
 
42
45
 
43
46
  def _codex_bootstrap_config_status() -> dict:
@@ -190,6 +193,7 @@ def _client_assumption_regressions() -> list[str]:
190
193
  return []
191
194
  allowed_claude_projects = {
192
195
  (src_root / "scripts" / "deep-sleep" / "collect.py").resolve(),
196
+ Path(__file__).resolve(),
193
197
  }
194
198
  offenders: list[str] = []
195
199
  for path in src_root.rglob("*.py"):
@@ -224,6 +228,44 @@ def _load_json(path: Path) -> dict:
224
228
  return json.loads(path.read_text())
225
229
 
226
230
 
231
+ def _latest_periodic_summary(kind: str) -> dict | None:
232
+ pattern = f"*-{kind}-summary.json"
233
+ candidates: list[tuple[str, Path]] = []
234
+ for path in (NEXO_HOME / "operations" / "deep-sleep").glob(pattern):
235
+ try:
236
+ payload = json.loads(path.read_text())
237
+ except Exception:
238
+ continue
239
+ label = str(payload.get("label", "") or "")
240
+ if label:
241
+ candidates.append((label, path))
242
+ if not candidates:
243
+ return None
244
+ _, path = sorted(candidates, key=lambda item: item[0])[-1]
245
+ try:
246
+ payload = json.loads(path.read_text())
247
+ except Exception:
248
+ return None
249
+ return payload if isinstance(payload, dict) else None
250
+
251
+
252
+ def _package_version() -> str:
253
+ try:
254
+ payload = json.loads(PACKAGE_JSON.read_text())
255
+ except Exception:
256
+ return ""
257
+ return str(payload.get("version", "") or "").strip()
258
+
259
+
260
+ def _top_changelog_version() -> str:
261
+ try:
262
+ text = CHANGELOG_FILE.read_text(encoding="utf-8")
263
+ except Exception:
264
+ return ""
265
+ match = re.search(r"^## \[([^\]]+)\]", text, flags=re.MULTILINE)
266
+ return match.group(1).strip() if match else ""
267
+
268
+
227
269
  def _count_checks(checks) -> int:
228
270
  if isinstance(checks, list):
229
271
  return len(checks)
@@ -1529,6 +1571,143 @@ def check_client_assumption_regressions() -> DoctorCheck:
1529
1571
  )
1530
1572
 
1531
1573
 
1574
+ def check_protocol_compliance() -> DoctorCheck:
1575
+ summary = _latest_periodic_summary("weekly")
1576
+ if not summary:
1577
+ return DoctorCheck(
1578
+ id="runtime.protocol_compliance",
1579
+ tier="runtime",
1580
+ status="degraded",
1581
+ severity="warn",
1582
+ summary="No weekly Deep Sleep protocol summary found",
1583
+ repair_plan=[
1584
+ "Run the Deep Sleep pipeline so weekly summaries include protocol compliance again",
1585
+ ],
1586
+ escalation_prompt=(
1587
+ "NEXO cannot verify heartbeat / guard_check / change_log compliance because the latest weekly Deep Sleep summary is missing."
1588
+ ),
1589
+ )
1590
+
1591
+ protocol = summary.get("protocol_summary") or {}
1592
+ overall = protocol.get("overall_compliance_pct")
1593
+ guard = protocol.get("guard_check") or {}
1594
+ heartbeat = protocol.get("heartbeat") or {}
1595
+ change_log = protocol.get("change_log") or {}
1596
+ evidence = [f"weekly summary: {summary.get('label', 'unknown')}"]
1597
+ if overall is not None:
1598
+ evidence.append(f"overall protocol compliance: {overall:.1f}%")
1599
+ if guard.get("compliance_pct") is not None:
1600
+ evidence.append(
1601
+ f"guard_check: {guard.get('executed', 0)}/{guard.get('required', 0)} ({guard['compliance_pct']:.1f}%)"
1602
+ )
1603
+ if heartbeat.get("compliance_pct") is not None:
1604
+ evidence.append(
1605
+ f"heartbeat with context: {heartbeat.get('with_context', 0)}/{heartbeat.get('total', 0)} ({heartbeat['compliance_pct']:.1f}%)"
1606
+ )
1607
+ if change_log.get("compliance_pct") is not None:
1608
+ evidence.append(
1609
+ f"change_log after edits: {change_log.get('logged', 0)}/{change_log.get('edits', 0)} ({change_log['compliance_pct']:.1f}%)"
1610
+ )
1611
+
1612
+ status = "healthy"
1613
+ severity = "info"
1614
+ repair_plan: list[str] = []
1615
+ if overall is None:
1616
+ status = "degraded"
1617
+ severity = "warn"
1618
+ repair_plan.append("Ensure Deep Sleep extractions keep writing protocol_summary data")
1619
+ elif overall < 45:
1620
+ status = "critical"
1621
+ severity = "error"
1622
+ elif overall < 70:
1623
+ status = "degraded"
1624
+ severity = "warn"
1625
+
1626
+ if status != "healthy":
1627
+ repair_plan.extend(
1628
+ [
1629
+ "Reinforce heartbeat discipline on every user message",
1630
+ "Call nexo_guard_check before production/shared edits",
1631
+ "Record production changes with nexo_change_log after editing",
1632
+ ]
1633
+ )
1634
+
1635
+ return DoctorCheck(
1636
+ id="runtime.protocol_compliance",
1637
+ tier="runtime",
1638
+ status=status,
1639
+ severity=severity,
1640
+ summary="Protocol compliance looks healthy" if status == "healthy" else "Protocol compliance needs hardening",
1641
+ evidence=evidence,
1642
+ repair_plan=repair_plan,
1643
+ escalation_prompt=(
1644
+ "Heartbeat / guard_check / change_log discipline is drifting. NEXO is at risk of repeating known errors and hiding change history."
1645
+ ) if status != "healthy" else "",
1646
+ )
1647
+
1648
+
1649
+ def check_release_artifact_sync() -> DoctorCheck:
1650
+ version = _package_version()
1651
+ changelog_version = _top_changelog_version()
1652
+ evidence = []
1653
+ status = "healthy"
1654
+ severity = "info"
1655
+ repair_plan: list[str] = []
1656
+
1657
+ if version:
1658
+ evidence.append(f"package version: {version}")
1659
+ if changelog_version:
1660
+ evidence.append(f"top changelog version: {changelog_version}")
1661
+
1662
+ if version and changelog_version and version != changelog_version:
1663
+ status = "critical"
1664
+ severity = "error"
1665
+ evidence.append("package/changelog release version mismatch")
1666
+ repair_plan.append("Bump or align CHANGELOG.md before publishing")
1667
+
1668
+ sync_script = NEXO_CODE / "scripts" / "sync_release_artifacts.py"
1669
+ if not sync_script.is_file():
1670
+ status = "critical"
1671
+ severity = "error"
1672
+ evidence.append(f"missing release artifact sync script at {sync_script}")
1673
+ repair_plan.append("Restore scripts/sync_release_artifacts.py")
1674
+ else:
1675
+ try:
1676
+ result = subprocess.run(
1677
+ [sys.executable, str(sync_script), "--check"],
1678
+ cwd=str(NEXO_CODE),
1679
+ capture_output=True,
1680
+ text=True,
1681
+ )
1682
+ except Exception as exc:
1683
+ status = "degraded" if status == "healthy" else status
1684
+ severity = "warn" if severity == "info" else severity
1685
+ evidence.append(f"artifact sync check failed to run: {exc}")
1686
+ repair_plan.append("Run scripts/sync_release_artifacts.py manually and inspect the local environment")
1687
+ else:
1688
+ if result.returncode != 0:
1689
+ status = "degraded" if status == "healthy" else status
1690
+ severity = "warn" if severity == "info" else severity
1691
+ detail = result.stderr.strip() or result.stdout.strip() or "artifact sync check failed"
1692
+ evidence.append(detail.splitlines()[0])
1693
+ repair_plan.append("Run scripts/sync_release_artifacts.py before publishing")
1694
+ else:
1695
+ evidence.append("release artifacts in sync")
1696
+
1697
+ return DoctorCheck(
1698
+ id="runtime.release_artifacts",
1699
+ tier="runtime",
1700
+ status=status,
1701
+ severity=severity,
1702
+ summary="Release artifact discipline OK" if status == "healthy" else "Release artifact discipline needs attention",
1703
+ evidence=evidence,
1704
+ repair_plan=repair_plan,
1705
+ escalation_prompt=(
1706
+ "Release-facing artifacts drifted away from the source version contract. Publishing now risks another hotfix release."
1707
+ ) if status != "healthy" else "",
1708
+ )
1709
+
1710
+
1532
1711
  def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
1533
1712
  """Run all runtime-tier checks. Read-only by default."""
1534
1713
  return [
@@ -1542,6 +1721,8 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
1542
1721
  check_claude_desktop_shared_brain(),
1543
1722
  check_transcript_source_parity(),
1544
1723
  check_client_assumption_regressions(),
1724
+ check_protocol_compliance(),
1725
+ check_release_artifact_sync(),
1545
1726
  check_launchagent_integrity(fix=fix),
1546
1727
  check_personal_script_registry(fix=fix),
1547
1728
  check_skill_health(fix=fix),
@@ -992,6 +992,201 @@ def _load_period_syntheses(target_date: str, *, window_days: int) -> list[dict]:
992
992
  return syntheses
993
993
 
994
994
 
995
+ def _load_period_extractions(target_date: str, *, window_days: int) -> list[dict]:
996
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
997
+ payloads: list[dict] = []
998
+ for offset in range(window_days):
999
+ date_str = (target_day - timedelta(days=offset)).strftime("%Y-%m-%d")
1000
+ path = DEEP_SLEEP_DIR / f"{date_str}-extractions.json"
1001
+ if not path.is_file():
1002
+ continue
1003
+ try:
1004
+ payload = json.loads(path.read_text())
1005
+ except Exception:
1006
+ continue
1007
+ if isinstance(payload, dict):
1008
+ payloads.append(payload)
1009
+ payloads.reverse()
1010
+ return payloads
1011
+
1012
+
1013
+ def _load_period_applied_logs(target_date: str, *, window_days: int) -> list[dict]:
1014
+ target_day = datetime.strptime(target_date, "%Y-%m-%d")
1015
+ payloads: list[dict] = []
1016
+ for offset in range(window_days):
1017
+ date_str = (target_day - timedelta(days=offset)).strftime("%Y-%m-%d")
1018
+ path = DEEP_SLEEP_DIR / f"{date_str}-applied.json"
1019
+ if not path.is_file():
1020
+ continue
1021
+ try:
1022
+ payload = json.loads(path.read_text())
1023
+ except Exception:
1024
+ continue
1025
+ if isinstance(payload, dict):
1026
+ payloads.append(payload)
1027
+ payloads.reverse()
1028
+ return payloads
1029
+
1030
+
1031
+ def _safe_pct(numerator: float, denominator: float) -> float | None:
1032
+ if denominator <= 0:
1033
+ return None
1034
+ return round((numerator / denominator) * 100.0, 1)
1035
+
1036
+
1037
+ def _aggregate_protocol_summary(extractions: list[dict]) -> dict:
1038
+ totals = {
1039
+ "sessions": 0,
1040
+ "guard_check": {"required": 0, "executed": 0},
1041
+ "heartbeat": {"total": 0, "with_context": 0},
1042
+ "change_log": {"edits": 0, "logged": 0},
1043
+ }
1044
+
1045
+ for payload in extractions:
1046
+ for item in payload.get("extractions", []) or []:
1047
+ if not isinstance(item, dict) or item.get("error"):
1048
+ continue
1049
+ totals["sessions"] += 1
1050
+ protocol_summary = item.get("protocol_summary") or {}
1051
+ for key in ("guard_check", "heartbeat", "change_log"):
1052
+ current = protocol_summary.get(key) or {}
1053
+ if key == "guard_check":
1054
+ totals[key]["required"] += int(current.get("required", 0) or 0)
1055
+ totals[key]["executed"] += int(current.get("executed", 0) or 0)
1056
+ elif key == "heartbeat":
1057
+ totals[key]["total"] += int(current.get("total", 0) or 0)
1058
+ totals[key]["with_context"] += int(current.get("with_context", 0) or 0)
1059
+ else:
1060
+ totals[key]["edits"] += int(current.get("edits", 0) or 0)
1061
+ totals[key]["logged"] += int(current.get("logged", 0) or 0)
1062
+
1063
+ guard_pct = _safe_pct(totals["guard_check"]["executed"], totals["guard_check"]["required"])
1064
+ heartbeat_pct = _safe_pct(totals["heartbeat"]["with_context"], totals["heartbeat"]["total"])
1065
+ change_pct = _safe_pct(totals["change_log"]["logged"], totals["change_log"]["edits"])
1066
+ available = [value for value in (guard_pct, heartbeat_pct, change_pct) if value is not None]
1067
+
1068
+ totals["guard_check"]["compliance_pct"] = guard_pct
1069
+ totals["heartbeat"]["compliance_pct"] = heartbeat_pct
1070
+ totals["change_log"]["compliance_pct"] = change_pct
1071
+ totals["overall_compliance_pct"] = round(sum(available) / len(available), 1) if available else None
1072
+ return totals
1073
+
1074
+
1075
+ def _aggregate_delivery_metrics(applied_logs: list[dict]) -> dict:
1076
+ totals = {
1077
+ "runs": len(applied_logs),
1078
+ "applied_actions": 0,
1079
+ "deferred_actions": 0,
1080
+ "skipped_dedupe": 0,
1081
+ "errors": 0,
1082
+ "engineering_followups": 0,
1083
+ }
1084
+ for payload in applied_logs:
1085
+ stats = payload.get("stats") or {}
1086
+ totals["applied_actions"] += int(stats.get("applied", 0) or 0)
1087
+ totals["deferred_actions"] += int(stats.get("deferred", 0) or 0)
1088
+ totals["skipped_dedupe"] += int(stats.get("skipped_dedupe", 0) or 0)
1089
+ totals["errors"] += int(stats.get("errors", 0) or 0)
1090
+ for action in payload.get("applied_actions", []) or []:
1091
+ details = action.get("details") or {}
1092
+ if action.get("action_type") == "followup_create":
1093
+ description = str(details.get("description", "") or "") + " " + str(details.get("reasoning", "") or "")
1094
+ if "engineering" in description.lower() or "guardrail" in description.lower():
1095
+ totals["engineering_followups"] += 1
1096
+
1097
+ attempted = totals["applied_actions"] + totals["deferred_actions"] + totals["skipped_dedupe"] + totals["errors"]
1098
+ totals["dedupe_rate_pct"] = _safe_pct(totals["skipped_dedupe"], attempted)
1099
+ totals["error_rate_pct"] = _safe_pct(totals["errors"], attempted)
1100
+ return totals
1101
+
1102
+
1103
+ def _load_previous_period_summary(kind: str, label: str) -> dict | None:
1104
+ pattern = f"*-{kind}-summary.json"
1105
+ candidates: list[tuple[str, Path]] = []
1106
+ for path in DEEP_SLEEP_DIR.glob(pattern):
1107
+ try:
1108
+ payload = json.loads(path.read_text())
1109
+ except Exception:
1110
+ continue
1111
+ candidate_label = str(payload.get("label", "") or "")
1112
+ if candidate_label and candidate_label < label:
1113
+ candidates.append((candidate_label, path))
1114
+ if not candidates:
1115
+ return None
1116
+ _, path = sorted(candidates, key=lambda item: item[0])[-1]
1117
+ try:
1118
+ payload = json.loads(path.read_text())
1119
+ except Exception:
1120
+ return None
1121
+ return payload if isinstance(payload, dict) else None
1122
+
1123
+
1124
+ def _build_project_pulse(top_projects: list[dict], previous_summary: dict | None) -> list[dict]:
1125
+ previous_scores: dict[str, float] = {}
1126
+ if previous_summary:
1127
+ for item in previous_summary.get("project_pulse", []) or previous_summary.get("top_projects", []) or []:
1128
+ project = str(item.get("project", "") or "")
1129
+ if project:
1130
+ previous_scores[project] = float(item.get("score", 0) or 0)
1131
+
1132
+ pulse: list[dict] = []
1133
+ for item in top_projects:
1134
+ project = str(item.get("project", "") or "")
1135
+ score = float(item.get("score", 0) or 0)
1136
+ previous_score = previous_scores.get(project, 0.0)
1137
+ delta = round(score - previous_score, 2)
1138
+ if score >= 18:
1139
+ status = "critical"
1140
+ elif score >= 10:
1141
+ status = "elevated"
1142
+ else:
1143
+ status = "watch"
1144
+ if delta >= 2.0:
1145
+ trend = "rising"
1146
+ elif delta <= -2.0:
1147
+ trend = "cooling"
1148
+ else:
1149
+ trend = "steady"
1150
+ pulse.append(
1151
+ {
1152
+ "project": project,
1153
+ "score": round(score, 2),
1154
+ "delta_vs_previous": delta,
1155
+ "trend": trend,
1156
+ "status": status,
1157
+ "signals": item.get("signals", {}),
1158
+ "reasons": item.get("reasons", []),
1159
+ }
1160
+ )
1161
+ return pulse
1162
+
1163
+
1164
+ def _build_period_trend(summary: dict, previous_summary: dict | None) -> dict:
1165
+ if not previous_summary:
1166
+ return {
1167
+ "has_previous": False,
1168
+ "avg_mood_delta": None,
1169
+ "avg_trust_delta": None,
1170
+ "total_corrections_delta": None,
1171
+ "protocol_compliance_delta": None,
1172
+ }
1173
+
1174
+ current_protocol = summary.get("protocol_summary", {}).get("overall_compliance_pct")
1175
+ previous_protocol = (previous_summary.get("protocol_summary") or {}).get("overall_compliance_pct")
1176
+ current_mood = summary.get("avg_mood_score")
1177
+ previous_mood = previous_summary.get("avg_mood_score")
1178
+ current_trust = summary.get("avg_trust_score")
1179
+ previous_trust = previous_summary.get("avg_trust_score")
1180
+
1181
+ return {
1182
+ "has_previous": True,
1183
+ "avg_mood_delta": round(current_mood - previous_mood, 3) if isinstance(current_mood, (int, float)) and isinstance(previous_mood, (int, float)) else None,
1184
+ "avg_trust_delta": round(current_trust - previous_trust, 1) if isinstance(current_trust, (int, float)) and isinstance(previous_trust, (int, float)) else None,
1185
+ "total_corrections_delta": int(summary.get("total_corrections", 0) or 0) - int(previous_summary.get("total_corrections", 0) or 0),
1186
+ "protocol_compliance_delta": round(current_protocol - previous_protocol, 1) if isinstance(current_protocol, (int, float)) and isinstance(previous_protocol, (int, float)) else None,
1187
+ }
1188
+
1189
+
995
1190
  def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, window_days: int) -> dict:
996
1191
  target_day = datetime.strptime(target_date, "%Y-%m-%d")
997
1192
  window_start = (target_day - timedelta(days=max(0, window_days - 1))).strftime("%Y-%m-%d")
@@ -1001,6 +1196,8 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
1001
1196
  else target_day.strftime("%Y-%m")
1002
1197
  )
1003
1198
  syntheses = _load_period_syntheses(target_date, window_days=window_days)
1199
+ extractions = _load_period_extractions(target_date, window_days=window_days)
1200
+ applied_logs = _load_period_applied_logs(target_date, window_days=window_days)
1004
1201
  if not any(item.get("date") == target_date for item in syntheses):
1005
1202
  syntheses.append(synthesis)
1006
1203
 
@@ -1037,6 +1234,10 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
1037
1234
  {"title": title, "count": count}
1038
1235
  for title, count in agenda_counter.most_common(6)
1039
1236
  ]
1237
+ protocol_summary = _aggregate_protocol_summary(extractions)
1238
+ delivery_metrics = _aggregate_delivery_metrics(applied_logs)
1239
+ previous_summary = _load_previous_period_summary(kind, label)
1240
+ project_pulse = _build_project_pulse(top_projects, previous_summary)
1040
1241
 
1041
1242
  summary_parts = [f"{len(syntheses)} Deep Sleep run(s)"]
1042
1243
  if top_projects:
@@ -1045,9 +1246,11 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
1045
1246
  summary_parts.append(f"recurring pattern: {top_patterns[0]['pattern']}")
1046
1247
  if avg_trust is not None:
1047
1248
  summary_parts.append(f"avg trust {avg_trust:.1f}")
1249
+ if protocol_summary.get("overall_compliance_pct") is not None:
1250
+ summary_parts.append(f"protocol {protocol_summary['overall_compliance_pct']:.1f}%")
1048
1251
  summary = " | ".join(summary_parts)
1049
1252
 
1050
- return {
1253
+ period_summary = {
1051
1254
  "kind": kind,
1052
1255
  "label": label,
1053
1256
  "window_days": window_days,
@@ -1059,11 +1262,15 @@ def _build_period_summary(target_date: str, synthesis: dict, *, kind: str, windo
1059
1262
  "avg_trust_score": avg_trust,
1060
1263
  "total_corrections": total_corrections,
1061
1264
  "top_projects": top_projects,
1265
+ "project_pulse": project_pulse,
1062
1266
  "top_patterns": top_patterns,
1063
1267
  "recurring_agenda": recurring_agenda,
1268
+ "protocol_summary": protocol_summary,
1269
+ "delivery_metrics": delivery_metrics,
1064
1270
  "summary": summary,
1065
1271
  }
1066
-
1272
+ period_summary["trend"] = _build_period_trend(period_summary, previous_summary)
1273
+ return period_summary
1067
1274
 
1068
1275
  def _render_period_summary_markdown(summary: dict) -> str:
1069
1276
  lines = [
@@ -1082,6 +1289,47 @@ def _render_period_summary_markdown(summary: dict) -> str:
1082
1289
  lines.append(f"> {summary['summary']}")
1083
1290
  lines.append("")
1084
1291
 
1292
+ protocol_summary = summary.get("protocol_summary") or {}
1293
+ if protocol_summary:
1294
+ lines.append("## Protocol Compliance")
1295
+ lines.append("")
1296
+ overall = protocol_summary.get("overall_compliance_pct")
1297
+ if overall is not None:
1298
+ lines.append(f"- Overall compliance: {overall:.1f}%")
1299
+ guard = protocol_summary.get("guard_check", {})
1300
+ heartbeat = protocol_summary.get("heartbeat", {})
1301
+ change_log = protocol_summary.get("change_log", {})
1302
+ if guard:
1303
+ lines.append(
1304
+ f"- guard_check: {guard.get('executed', 0)}/{guard.get('required', 0)}"
1305
+ + (f" ({guard['compliance_pct']:.1f}%)" if guard.get("compliance_pct") is not None else "")
1306
+ )
1307
+ if heartbeat:
1308
+ lines.append(
1309
+ f"- heartbeat with context: {heartbeat.get('with_context', 0)}/{heartbeat.get('total', 0)}"
1310
+ + (f" ({heartbeat['compliance_pct']:.1f}%)" if heartbeat.get("compliance_pct") is not None else "")
1311
+ )
1312
+ if change_log:
1313
+ lines.append(
1314
+ f"- change_log after edits: {change_log.get('logged', 0)}/{change_log.get('edits', 0)}"
1315
+ + (f" ({change_log['compliance_pct']:.1f}%)" if change_log.get("compliance_pct") is not None else "")
1316
+ )
1317
+ lines.append("")
1318
+
1319
+ delivery_metrics = summary.get("delivery_metrics") or {}
1320
+ if delivery_metrics:
1321
+ lines.append("## Loop Output")
1322
+ lines.append("")
1323
+ lines.append(f"- Applied actions: {delivery_metrics.get('applied_actions', 0)}")
1324
+ lines.append(f"- Deferred actions: {delivery_metrics.get('deferred_actions', 0)}")
1325
+ lines.append(f"- Dedupe skips: {delivery_metrics.get('skipped_dedupe', 0)}")
1326
+ lines.append(f"- Engineering followups: {delivery_metrics.get('engineering_followups', 0)}")
1327
+ if delivery_metrics.get("dedupe_rate_pct") is not None:
1328
+ lines.append(f"- Dedupe rate: {delivery_metrics['dedupe_rate_pct']:.1f}%")
1329
+ if delivery_metrics.get("error_rate_pct") is not None:
1330
+ lines.append(f"- Error rate: {delivery_metrics['error_rate_pct']:.1f}%")
1331
+ lines.append("")
1332
+
1085
1333
  if summary.get("top_projects"):
1086
1334
  lines.append("## Top Projects")
1087
1335
  lines.append("")
@@ -1091,6 +1339,20 @@ def _render_period_summary_markdown(summary: dict) -> str:
1091
1339
  lines.append(f" Reasons: {', '.join(item['reasons'])}")
1092
1340
  lines.append("")
1093
1341
 
1342
+ if summary.get("project_pulse"):
1343
+ lines.append("## Project Pulse")
1344
+ lines.append("")
1345
+ for item in summary["project_pulse"][:5]:
1346
+ delta = item.get("delta_vs_previous")
1347
+ delta_label = ""
1348
+ if isinstance(delta, (int, float)):
1349
+ delta_label = f" | Δ {delta:+.2f}"
1350
+ lines.append(
1351
+ f"- **{item['project']}** — {item.get('status', 'watch')} / {item.get('trend', 'steady')}"
1352
+ f" | score {item.get('score', 0)}{delta_label}"
1353
+ )
1354
+ lines.append("")
1355
+
1094
1356
  if summary.get("top_patterns"):
1095
1357
  lines.append("## Recurring Patterns")
1096
1358
  lines.append("")
@@ -1105,6 +1367,20 @@ def _render_period_summary_markdown(summary: dict) -> str:
1105
1367
  lines.append(f"- {item['title']} ({item['count']}x)")
1106
1368
  lines.append("")
1107
1369
 
1370
+ trend = summary.get("trend") or {}
1371
+ if trend.get("has_previous"):
1372
+ lines.append("## Trend vs Previous")
1373
+ lines.append("")
1374
+ if trend.get("avg_mood_delta") is not None:
1375
+ lines.append(f"- Mood delta: {trend['avg_mood_delta']:+.3f}")
1376
+ if trend.get("avg_trust_delta") is not None:
1377
+ lines.append(f"- Trust delta: {trend['avg_trust_delta']:+.1f}")
1378
+ if trend.get("total_corrections_delta") is not None:
1379
+ lines.append(f"- Corrections delta: {trend['total_corrections_delta']:+d}")
1380
+ if trend.get("protocol_compliance_delta") is not None:
1381
+ lines.append(f"- Protocol delta: {trend['protocol_compliance_delta']:+.1f}%")
1382
+ lines.append("")
1383
+
1108
1384
  return "\n".join(lines).rstrip() + "\n"
1109
1385
 
1110
1386