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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +16 -18
- package/package.json +1 -1
- package/src/agent_runner.py +6 -2
- package/src/cli.py +98 -4
- package/src/client_preferences.py +21 -0
- package/src/dashboard/app.py +124 -0
- package/src/dashboard/templates/dashboard.html +59 -1
- package/src/doctor/providers/runtime.py +181 -0
- package/src/scripts/deep-sleep/apply_findings.py +278 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "2.
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|
package/src/agent_runner.py
CHANGED
|
@@ -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=
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/src/dashboard/app.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|