nexo-brain 2.6.20 → 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.20",
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,17 +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, and `2.6.20` makes the recommended Claude profile explicit across installer, runtime defaults, existing installs, and the update path itself: `Opus 4.6 with 1M context`.
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
- - 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.
51
- - 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.
52
50
 
53
51
  ### Client Capability Matrix
54
52
 
@@ -522,7 +520,7 @@ npx nexo-brain # detects current version, migrates automatically
522
520
 
523
521
  NEXO Brain includes a local CLI that runs independently of any single terminal client:
524
522
 
525
- - `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
526
524
  - `nexo update` — sync runtime from source, run migrations, reconcile schedules
527
525
  - `nexo doctor --tier runtime` — boot/runtime/deep diagnostics with `--fix` mode
528
526
  - `nexo scripts list` — list all personal scripts and their status
@@ -635,20 +633,20 @@ The installer handles everything and syncs the same `nexo` MCP brain into Claude
635
633
  After install, use the runtime CLI:
636
634
 
637
635
  ```bash
638
- 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)
639
637
  nexo doctor # Check runtime health
640
638
  nexo update # Pull latest version and sync
641
639
  nexo clients sync # Re-sync Claude Code/Desktop/Codex to the same brain
642
640
  nexo scripts list # See your personal scripts
643
641
  ```
644
642
 
645
- 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.
646
644
 
647
645
  Recommended defaults:
648
646
  - Claude Code: `Opus 4.6 with 1M context`
649
647
  - Codex: `gpt-5.4` with `xhigh` reasoning
650
648
 
651
- 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.
652
650
 
653
651
  Your operator will greet you immediately — adapted to the time of day, resuming from where you left off. No cold starts.
654
652
 
@@ -664,6 +662,7 @@ The project still recommends Claude Code as the primary path, but contributions
664
662
 
665
663
  Maintainers and contributors touching startup, bootstrap, Deep Sleep, or shared-brain behavior should also use the client parity checklist:
666
664
  - [docs/client-parity-checklist.md](docs/client-parity-checklist.md)
665
+ - `python3 scripts/verify_release_readiness.py`
667
666
 
668
667
  ### What Gets Installed
669
668
 
@@ -841,7 +840,7 @@ When Claude Desktop is installed, `nexo-brain`, `nexo update`, and `nexo clients
841
840
 
842
841
  ### Codex
843
842
 
844
- 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.
845
844
 
846
845
  ### OpenClaw
847
846
 
@@ -955,7 +954,7 @@ If NEXO Brain is useful to you, consider:
955
954
  - **Personal scripts registry**: Scripts in `NEXO_HOME/scripts/` tracked in SQLite with metadata, categories, schedules. Full lifecycle: create, sync, reconcile, schedule, unschedule, remove.
956
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/`.
957
956
  - **Claude Code plugin structure**: `plugin.json`, entry point, packaging for marketplace submission.
958
- - **`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.
959
958
  - **Managed Evolution hardening**: Can modify core behavior modules with rollback followups.
960
959
  - Cron recovery hardened: TCC diagnostics, keepalive sync, personal schedule catchup.
961
960
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.20",
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();