nexo-brain 2.6.10 → 2.6.11

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.10",
3
+ "version": "2.6.11",
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/bin/nexo-brain.js CHANGED
@@ -89,6 +89,44 @@ function syncWatchdogHashRegistry(nexoHome) {
89
89
  }
90
90
  }
91
91
 
92
+ function getCoreRuntimeFlatFiles() {
93
+ return [
94
+ "server.py",
95
+ "plugin_loader.py",
96
+ "knowledge_graph.py",
97
+ "kg_populate.py",
98
+ "maintenance.py",
99
+ "storage_router.py",
100
+ "claim_graph.py",
101
+ "hnsw_index.py",
102
+ "evolution_cycle.py",
103
+ "migrate_embeddings.py",
104
+ "auto_close_sessions.py",
105
+ "client_sync.py",
106
+ "auto_update.py",
107
+ "tools_sessions.py",
108
+ "tools_coordination.py",
109
+ "tools_reminders.py",
110
+ "tools_reminders_crud.py",
111
+ "tools_learnings.py",
112
+ "tools_credentials.py",
113
+ "tools_task_history.py",
114
+ "tools_menu.py",
115
+ "cli.py",
116
+ "script_registry.py",
117
+ "skills_runtime.py",
118
+ "user_context.py",
119
+ "public_contribution.py",
120
+ "cron_recovery.py",
121
+ "runtime_power.py",
122
+ "requirements.txt",
123
+ ];
124
+ }
125
+
126
+ function getCoreRuntimePackages() {
127
+ return ["db", "cognitive", "doctor"];
128
+ }
129
+
92
130
  function isProtectedMacPath(candidate) {
93
131
  if (process.platform !== "darwin" || !candidate) return false;
94
132
  const homeDir = require("os").homedir();
@@ -981,16 +1019,7 @@ async function main() {
981
1019
  log(" Hooks updated.");
982
1020
 
983
1021
  // Update core Python files (flat .py files in src/)
984
- const coreFlatFiles = [
985
- "server.py", "plugin_loader.py",
986
- "knowledge_graph.py", "kg_populate.py", "maintenance.py", "storage_router.py",
987
- "claim_graph.py", "hnsw_index.py", "evolution_cycle.py", "migrate_embeddings.py",
988
- "auto_close_sessions.py", "auto_update.py",
989
- "tools_sessions.py", "tools_coordination.py", "tools_reminders.py",
990
- "tools_reminders_crud.py", "tools_learnings.py", "tools_credentials.py",
991
- "tools_task_history.py", "tools_menu.py",
992
- "requirements.txt",
993
- ];
1022
+ const coreFlatFiles = getCoreRuntimeFlatFiles();
994
1023
  coreFlatFiles.forEach((f) => {
995
1024
  const src = path.join(srcDir, f);
996
1025
  if (fs.existsSync(src)) {
@@ -998,7 +1027,7 @@ async function main() {
998
1027
  }
999
1028
  });
1000
1029
  // Update core packages (db/, cognitive/) — full directory copy
1001
- ["db", "cognitive"].forEach(pkg => {
1030
+ getCoreRuntimePackages().forEach(pkg => {
1002
1031
  const pkgSrc = path.join(srcDir, pkg);
1003
1032
  if (fs.existsSync(pkgSrc)) {
1004
1033
  copyDirRec(pkgSrc, path.join(NEXO_HOME, pkg));
@@ -1752,33 +1781,7 @@ async function main() {
1752
1781
  };
1753
1782
 
1754
1783
  // Core flat files (single .py files in src/)
1755
- const coreFiles = [
1756
- "server.py",
1757
- "plugin_loader.py",
1758
- "knowledge_graph.py",
1759
- "kg_populate.py",
1760
- "maintenance.py",
1761
- "storage_router.py",
1762
- "claim_graph.py",
1763
- "hnsw_index.py",
1764
- "evolution_cycle.py",
1765
- "migrate_embeddings.py",
1766
- "auto_close_sessions.py",
1767
- "client_sync.py",
1768
- "auto_update.py",
1769
- "tools_sessions.py",
1770
- "tools_coordination.py",
1771
- "tools_reminders.py",
1772
- "tools_reminders_crud.py",
1773
- "tools_learnings.py",
1774
- "tools_credentials.py",
1775
- "tools_task_history.py",
1776
- "tools_menu.py",
1777
- "requirements.txt",
1778
- "cli.py",
1779
- "script_registry.py",
1780
- "skills_runtime.py",
1781
- ];
1784
+ const coreFiles = getCoreRuntimeFlatFiles();
1782
1785
  coreFiles.forEach((f) => {
1783
1786
  const src = path.join(srcDir, f);
1784
1787
  if (fs.existsSync(src)) {
@@ -1811,7 +1814,7 @@ async function main() {
1811
1814
 
1812
1815
  log("Copying core packages...");
1813
1816
  // Core packages (directories with __init__.py)
1814
- ["db", "cognitive", "doctor"].forEach(pkg => {
1817
+ getCoreRuntimePackages().forEach(pkg => {
1815
1818
  const pkgSrc = path.join(srcDir, pkg);
1816
1819
  if (fs.existsSync(pkgSrc)) {
1817
1820
  copyDirRecursive(pkgSrc, path.join(NEXO_HOME, pkg));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.10",
3
+ "version": "2.6.11",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO — local cognitive runtime for Claude Code. Persistent memory, overnight learning, recovery-aware crons, personal scripts, doctor diagnostics, startup preflight, and optional power helper.",
6
6
  "bin": {
@@ -1202,6 +1202,7 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
1202
1202
  "tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
1203
1203
  "tools_credentials.py", "tools_task_history.py", "tools_menu.py",
1204
1204
  "cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
1205
+ "public_contribution.py",
1205
1206
  "cron_recovery.py", "runtime_power.py", "requirements.txt", "package.json", "version.json",
1206
1207
  ]
1207
1208
  for name in code_dirs:
@@ -1250,6 +1251,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1250
1251
  "tools_reminders.py", "tools_reminders_crud.py", "tools_learnings.py",
1251
1252
  "tools_credentials.py", "tools_task_history.py", "tools_menu.py",
1252
1253
  "cli.py", "script_registry.py", "skills_runtime.py", "user_context.py",
1254
+ "public_contribution.py",
1253
1255
  "cron_recovery.py", "runtime_power.py", "requirements.txt",
1254
1256
  ]
1255
1257
  copied_packages = 0
@@ -1383,12 +1385,14 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
1383
1385
  sys.executable,
1384
1386
  "-c",
1385
1387
  (
1388
+ "import json; "
1386
1389
  "import db; "
1387
1390
  "init_db = getattr(db, 'init_db', None); "
1388
1391
  "init_db() if callable(init_db) else None; "
1389
1392
  "import script_registry; "
1390
1393
  "reconcile_scripts = getattr(script_registry, 'reconcile_personal_scripts', None); "
1391
- "reconcile_scripts(dry_run=False) if callable(reconcile_scripts) else None"
1394
+ "result = reconcile_scripts(dry_run=False) if callable(reconcile_scripts) else {}; "
1395
+ "print(json.dumps(result))"
1392
1396
  ),
1393
1397
  ],
1394
1398
  cwd=str(dest),
@@ -1400,6 +1404,11 @@ def _run_runtime_post_sync(dest: Path = NEXO_HOME, progress_fn=None) -> tuple[bo
1400
1404
  if init_result.returncode != 0:
1401
1405
  return False, [init_result.stderr.strip() or init_result.stdout.strip() or "runtime init failed"]
1402
1406
  actions.append("db+personal-sync")
1407
+ reconcile_payload = _parse_runtime_init_payload(init_result.stdout or "")
1408
+ extra_actions, reconcile_message = _personal_schedule_reconcile_summary(reconcile_payload)
1409
+ actions.extend(extra_actions)
1410
+ if reconcile_message:
1411
+ _emit_progress(progress_fn, reconcile_message)
1403
1412
  except Exception as e:
1404
1413
  return False, [f"runtime init error: {e}"]
1405
1414
 
@@ -1493,6 +1502,46 @@ def _emit_progress(progress_fn, message: str) -> None:
1493
1502
  pass
1494
1503
 
1495
1504
 
1505
+ def _parse_runtime_init_payload(stdout: str) -> dict:
1506
+ """Extract the JSON payload emitted by the runtime init helper."""
1507
+ lines = [line.strip() for line in stdout.splitlines() if line.strip()]
1508
+ for line in reversed(lines):
1509
+ try:
1510
+ payload = json.loads(line)
1511
+ except Exception:
1512
+ continue
1513
+ if isinstance(payload, dict):
1514
+ return payload
1515
+ return {}
1516
+
1517
+
1518
+ def _personal_schedule_reconcile_summary(reconcile_result: dict) -> tuple[list[str], str | None]:
1519
+ """Turn reconcile_personal_scripts() output into stable update actions."""
1520
+ if not isinstance(reconcile_result, dict):
1521
+ return [], None
1522
+
1523
+ ensured = reconcile_result.get("ensure_schedules", {})
1524
+ if not isinstance(ensured, dict):
1525
+ return [], None
1526
+
1527
+ created = len(ensured.get("created", []) or [])
1528
+ repaired = len(ensured.get("repaired", []) or [])
1529
+ invalid = len(ensured.get("invalid", []) or [])
1530
+
1531
+ actions: list[str] = []
1532
+ parts: list[str] = []
1533
+ if created or repaired:
1534
+ actions.append(f"personal-schedules-healed:{created + repaired}")
1535
+ parts.append(f"{created} created")
1536
+ parts.append(f"{repaired} repaired")
1537
+ if invalid:
1538
+ actions.append(f"personal-schedules-invalid:{invalid}")
1539
+ parts.append(f"{invalid} invalid")
1540
+ if not parts:
1541
+ return [], None
1542
+ return actions, "Personal schedules: " + ", ".join(parts) + "."
1543
+
1544
+
1496
1545
  def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = True, progress_fn=None) -> dict:
1497
1546
  src_dir, repo_dir = _resolve_sync_source()
1498
1547
  if src_dir is None or repo_dir is None:
@@ -1599,8 +1648,12 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1599
1648
  _ensure_runtime_cli_wrapper()
1600
1649
  _ensure_runtime_cli_in_shell()
1601
1650
  init_db()
1602
- reconcile_personal_scripts(dry_run=False)
1651
+ reconcile_result = reconcile_personal_scripts(dry_run=False)
1603
1652
  result["actions"].append("db+personal-sync")
1653
+ extra_actions, reconcile_message = _personal_schedule_reconcile_summary(reconcile_result)
1654
+ result["actions"].extend(extra_actions)
1655
+ if reconcile_message:
1656
+ _log(reconcile_message)
1604
1657
  except Exception as e:
1605
1658
  result["error"] = str(e)
1606
1659
  _write_update_summary(result)
package/src/cli.py CHANGED
@@ -64,6 +64,123 @@ if str(NEXO_CODE) not in sys.path:
64
64
  sys.path.insert(0, str(NEXO_CODE))
65
65
 
66
66
 
67
+ def _missing_runtime_module_message(module_name: str, exc: Exception) -> str:
68
+ missing = getattr(exc, "name", None) or module_name
69
+ return (
70
+ f"{module_name} is unavailable in the current runtime ({missing}). "
71
+ "Continuing with safe defaults so `nexo update` can repair the installation."
72
+ )
73
+
74
+
75
+ def _load_runtime_power_support() -> dict:
76
+ try:
77
+ from runtime_power import (
78
+ ensure_power_policy_choice,
79
+ apply_power_policy,
80
+ format_power_policy_label,
81
+ ensure_full_disk_access_choice,
82
+ format_full_disk_access_label,
83
+ )
84
+ return {
85
+ "available": True,
86
+ "message": "",
87
+ "ensure_power_policy_choice": ensure_power_policy_choice,
88
+ "apply_power_policy": apply_power_policy,
89
+ "format_power_policy_label": format_power_policy_label,
90
+ "ensure_full_disk_access_choice": ensure_full_disk_access_choice,
91
+ "format_full_disk_access_label": format_full_disk_access_label,
92
+ }
93
+ except ImportError as exc:
94
+ message = _missing_runtime_module_message("runtime_power", exc)
95
+
96
+ def ensure_power_policy_choice(**kwargs):
97
+ return {"policy": "disabled", "prompted": False, "message": message}
98
+
99
+ def apply_power_policy(policy=None):
100
+ return {"ok": True, "action": "skipped", "details": [], "message": message}
101
+
102
+ def format_power_policy_label(policy):
103
+ return policy or "disabled"
104
+
105
+ def ensure_full_disk_access_choice(**kwargs):
106
+ return {"status": "unset", "prompted": False, "reasons": [], "message": message}
107
+
108
+ def format_full_disk_access_label(status):
109
+ return status or "unset"
110
+
111
+ return {
112
+ "available": False,
113
+ "message": message,
114
+ "ensure_power_policy_choice": ensure_power_policy_choice,
115
+ "apply_power_policy": apply_power_policy,
116
+ "format_power_policy_label": format_power_policy_label,
117
+ "ensure_full_disk_access_choice": ensure_full_disk_access_choice,
118
+ "format_full_disk_access_label": format_full_disk_access_label,
119
+ }
120
+
121
+
122
+ def _load_public_contribution_support() -> dict:
123
+ try:
124
+ from public_contribution import (
125
+ ensure_public_contribution_choice,
126
+ format_public_contribution_label,
127
+ load_public_contribution_config,
128
+ refresh_public_contribution_state,
129
+ disable_public_contribution,
130
+ )
131
+ return {
132
+ "available": True,
133
+ "message": "",
134
+ "ensure_public_contribution_choice": ensure_public_contribution_choice,
135
+ "format_public_contribution_label": format_public_contribution_label,
136
+ "load_public_contribution_config": load_public_contribution_config,
137
+ "refresh_public_contribution_state": refresh_public_contribution_state,
138
+ "disable_public_contribution": disable_public_contribution,
139
+ }
140
+ except ImportError as exc:
141
+ message = _missing_runtime_module_message("public_contribution", exc)
142
+
143
+ def _default_config(config=None):
144
+ payload = {
145
+ "enabled": False,
146
+ "mode": "disabled",
147
+ "status": "unavailable",
148
+ "prompted": False,
149
+ "message": message,
150
+ }
151
+ if isinstance(config, dict):
152
+ payload.update(config)
153
+ return payload
154
+
155
+ def ensure_public_contribution_choice(**kwargs):
156
+ return _default_config()
157
+
158
+ def format_public_contribution_label(config=None):
159
+ cfg = _default_config(config)
160
+ if cfg.get("status") == "unavailable":
161
+ return "disabled (runtime repair needed)"
162
+ return cfg.get("mode") or "disabled"
163
+
164
+ def load_public_contribution_config():
165
+ return _default_config()
166
+
167
+ def refresh_public_contribution_state(config=None):
168
+ return _default_config(config)
169
+
170
+ def disable_public_contribution():
171
+ return _default_config()
172
+
173
+ return {
174
+ "available": False,
175
+ "message": message,
176
+ "ensure_public_contribution_choice": ensure_public_contribution_choice,
177
+ "format_public_contribution_label": format_public_contribution_label,
178
+ "load_public_contribution_config": load_public_contribution_config,
179
+ "refresh_public_contribution_state": refresh_public_contribution_state,
180
+ "disable_public_contribution": disable_public_contribution,
181
+ }
182
+
183
+
67
184
  def _scripts_list(args):
68
185
  from db import init_db, list_personal_scripts
69
186
  from script_registry import list_scripts, sync_personal_scripts
@@ -453,17 +570,6 @@ def _update(args):
453
570
  - Packaged/runtime-only install: delegate to plugins.update handle_update()
454
571
  """
455
572
  from auto_update import manual_sync_update, _resolve_sync_source
456
- from runtime_power import (
457
- ensure_power_policy_choice,
458
- apply_power_policy,
459
- format_power_policy_label,
460
- ensure_full_disk_access_choice,
461
- format_full_disk_access_label,
462
- )
463
- from public_contribution import (
464
- ensure_public_contribution_choice,
465
- format_public_contribution_label,
466
- )
467
573
 
468
574
  interactive = sys.stdin.isatty() and sys.stdout.isatty()
469
575
  progress_messages: list[str] = []
@@ -488,10 +594,12 @@ def _update(args):
488
594
  return 1
489
595
 
490
596
  result = handle_update(progress_fn=progress)
491
- choice = ensure_power_policy_choice(interactive=interactive, reason="update")
492
- power_result = apply_power_policy(choice.get("policy"))
493
- fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
494
- contrib_choice = ensure_public_contribution_choice(interactive=interactive, reason="update")
597
+ runtime_power = _load_runtime_power_support()
598
+ public_contribution = _load_public_contribution_support()
599
+ choice = runtime_power["ensure_power_policy_choice"](interactive=interactive, reason="update")
600
+ power_result = runtime_power["apply_power_policy"](choice.get("policy"))
601
+ fda_choice = runtime_power["ensure_full_disk_access_choice"](interactive=interactive, reason="update")
602
+ contrib_choice = public_contribution["ensure_public_contribution_choice"](interactive=interactive, reason="update")
495
603
  if args.json:
496
604
  print(json.dumps({
497
605
  "mode": "packaged",
@@ -510,24 +618,26 @@ def _update(args):
510
618
  else:
511
619
  print(result)
512
620
  if choice.get("prompted"):
513
- print(f"Power policy: {format_power_policy_label(choice.get('policy'))}")
621
+ print(f"Power policy: {runtime_power['format_power_policy_label'](choice.get('policy'))}")
514
622
  if power_result.get("message"):
515
623
  print(f"Power helper: {power_result.get('message')}")
516
624
  if fda_choice.get("prompted"):
517
- print(f"Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
625
+ print(f"Full Disk Access: {runtime_power['format_full_disk_access_label'](fda_choice.get('status'))}")
518
626
  if fda_choice.get("message"):
519
627
  print(f"Full Disk Access: {fda_choice.get('message')}")
520
628
  if contrib_choice.get("prompted"):
521
- print(f"Contributor mode: {format_public_contribution_label(contrib_choice)}")
629
+ print(f"Contributor mode: {public_contribution['format_public_contribution_label'](contrib_choice)}")
522
630
  if contrib_choice.get("message"):
523
631
  print(f"Contributor mode: {contrib_choice.get('message')}")
524
632
  return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
525
633
 
526
- choice = ensure_power_policy_choice(interactive=interactive, reason="update")
527
- power_result = apply_power_policy(choice.get("policy"))
528
- fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
529
- contrib_choice = ensure_public_contribution_choice(interactive=interactive, reason="update")
530
634
  result = manual_sync_update(interactive=interactive, allow_source_pull=True, progress_fn=progress)
635
+ runtime_power = _load_runtime_power_support()
636
+ public_contribution = _load_public_contribution_support()
637
+ choice = runtime_power["ensure_power_policy_choice"](interactive=interactive, reason="update")
638
+ power_result = runtime_power["apply_power_policy"](choice.get("policy"))
639
+ fda_choice = runtime_power["ensure_full_disk_access_choice"](interactive=interactive, reason="update")
640
+ contrib_choice = public_contribution["ensure_public_contribution_choice"](interactive=interactive, reason="update")
531
641
  result["power_policy"] = choice.get("policy")
532
642
  result["power_action"] = power_result.get("action")
533
643
  result["power_details"] = power_result.get("details")
@@ -551,18 +661,35 @@ def _update(args):
551
661
  f" {result.get('packages', 0)} packages, {result.get('files', 0)} files synced from "
552
662
  f"{result.get('source', src_dir)}"
553
663
  )
664
+ healed = 0
665
+ invalid = 0
666
+ for action in result.get("actions", []):
667
+ if action.startswith("personal-schedules-healed:"):
668
+ try:
669
+ healed += int(action.split(":", 1)[1])
670
+ except ValueError:
671
+ pass
672
+ elif action.startswith("personal-schedules-invalid:"):
673
+ try:
674
+ invalid += int(action.split(":", 1)[1])
675
+ except ValueError:
676
+ pass
677
+ if healed:
678
+ print(f" Personal schedules: self-healed {healed}")
679
+ if invalid:
680
+ print(f" Personal schedules: {invalid} declarations need review")
554
681
  if result.get("pulled_source"):
555
682
  print(" Source repo: pulled latest fast-forward before sync")
556
683
  if choice.get("prompted"):
557
- print(f" Power policy: {format_power_policy_label(choice.get('policy'))}")
684
+ print(f" Power policy: {runtime_power['format_power_policy_label'](choice.get('policy'))}")
558
685
  if power_result.get("message"):
559
686
  print(f" Power helper: {power_result.get('message')}")
560
687
  if fda_choice.get("prompted"):
561
- print(f" Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
688
+ print(f" Full Disk Access: {runtime_power['format_full_disk_access_label'](fda_choice.get('status'))}")
562
689
  if fda_choice.get("message"):
563
690
  print(f" Full Disk Access: {fda_choice.get('message')}")
564
691
  if contrib_choice.get("prompted"):
565
- print(f" Contributor mode: {format_public_contribution_label(contrib_choice)}")
692
+ print(f" Contributor mode: {public_contribution['format_public_contribution_label'](contrib_choice)}")
566
693
  if contrib_choice.get("message"):
567
694
  print(f" Contributor mode: {contrib_choice.get('message')}")
568
695
  else:
@@ -582,29 +709,29 @@ def _clients_sync(args):
582
709
 
583
710
 
584
711
  def _contributor_status(args):
585
- from public_contribution import (
586
- format_public_contribution_label,
587
- load_public_contribution_config,
588
- refresh_public_contribution_state,
712
+ public_contribution = _load_public_contribution_support()
713
+ config = public_contribution["refresh_public_contribution_state"](
714
+ public_contribution["load_public_contribution_config"]()
589
715
  )
590
-
591
- config = refresh_public_contribution_state(load_public_contribution_config())
592
716
  payload = {
593
717
  "enabled": bool(config.get("enabled")),
594
718
  "mode": config.get("mode"),
595
719
  "status": config.get("status"),
596
- "label": format_public_contribution_label(config),
720
+ "label": public_contribution["format_public_contribution_label"](config),
597
721
  "github_user": config.get("github_user"),
598
722
  "fork_repo": config.get("fork_repo"),
599
723
  "active_pr_url": config.get("active_pr_url"),
600
724
  "active_branch": config.get("active_branch"),
601
725
  "cooldown_until": config.get("cooldown_until"),
602
726
  "last_result": config.get("last_result"),
727
+ "message": config.get("message") or public_contribution.get("message"),
603
728
  }
604
729
  if args.json:
605
730
  print(json.dumps(payload, indent=2, ensure_ascii=False))
606
731
  else:
607
732
  print(f"Contributor mode: {payload['label']}")
733
+ if payload["message"]:
734
+ print(f" {payload['message']}")
608
735
  if payload["github_user"]:
609
736
  print(f" GitHub user: {payload['github_user']}")
610
737
  if payload["fork_repo"]:
@@ -619,30 +746,40 @@ def _contributor_status(args):
619
746
 
620
747
 
621
748
  def _contributor_on(args):
622
- from public_contribution import ensure_public_contribution_choice, format_public_contribution_label
749
+ public_contribution = _load_public_contribution_support()
623
750
 
624
751
  interactive = sys.stdin.isatty() and sys.stdout.isatty()
625
752
  if not interactive:
626
753
  print("Contributor mode requires an interactive terminal to confirm GitHub Draft PR consent.", file=sys.stderr)
627
754
  return 1
628
- config = ensure_public_contribution_choice(interactive=True, reason="contributor", force_prompt=True)
755
+ if not public_contribution["available"]:
756
+ print(public_contribution["message"], file=sys.stderr)
757
+ return 1
758
+ config = public_contribution["ensure_public_contribution_choice"](
759
+ interactive=True,
760
+ reason="contributor",
761
+ force_prompt=True,
762
+ )
629
763
  if args.json:
630
764
  print(json.dumps(config, indent=2, ensure_ascii=False))
631
765
  else:
632
- print(f"Contributor mode: {format_public_contribution_label(config)}")
766
+ print(f"Contributor mode: {public_contribution['format_public_contribution_label'](config)}")
633
767
  if config.get("message"):
634
768
  print(config.get("message"))
635
769
  return 0 if config.get("mode") == "draft_prs" else 1
636
770
 
637
771
 
638
772
  def _contributor_off(args):
639
- from public_contribution import disable_public_contribution, format_public_contribution_label
773
+ public_contribution = _load_public_contribution_support()
640
774
 
641
- config = disable_public_contribution()
775
+ if not public_contribution["available"]:
776
+ print(public_contribution["message"], file=sys.stderr)
777
+ return 1
778
+ config = public_contribution["disable_public_contribution"]()
642
779
  if args.json:
643
780
  print(json.dumps(config, indent=2, ensure_ascii=False))
644
781
  else:
645
- print(f"Contributor mode: {format_public_contribution_label(config)}")
782
+ print(f"Contributor mode: {public_contribution['format_public_contribution_label'](config)}")
646
783
  return 0
647
784
 
648
785
 
@@ -120,6 +120,30 @@ def _acquire_lock():
120
120
  return handle
121
121
 
122
122
 
123
+ def _heal_personal_schedules() -> dict:
124
+ """Recreate declared personal schedules before catch-up checks missed windows."""
125
+ summary = {"created": 0, "repaired": 0, "invalid": 0, "error": ""}
126
+ try:
127
+ from script_registry import reconcile_personal_scripts
128
+
129
+ result = reconcile_personal_scripts(dry_run=False)
130
+ ensured = result.get("ensure_schedules", {})
131
+ summary["created"] = len(ensured.get("created", []))
132
+ summary["repaired"] = len(ensured.get("repaired", []))
133
+ summary["invalid"] = len(ensured.get("invalid", []))
134
+ if summary["created"] or summary["repaired"]:
135
+ log(
136
+ "Repaired declared personal schedules before catch-up: "
137
+ f"{summary['created']} created, {summary['repaired']} repaired."
138
+ )
139
+ if summary["invalid"]:
140
+ log(f"WARNING: {summary['invalid']} declared personal schedules are invalid.")
141
+ except Exception as e:
142
+ summary["error"] = str(e)
143
+ log(f"Personal schedule self-heal skipped: {e}")
144
+ return summary
145
+
146
+
123
147
  def run_task(candidate: dict, state: dict) -> bool:
124
148
  """Execute a task and update state."""
125
149
  name = candidate["cron_id"]
@@ -172,6 +196,7 @@ def main():
172
196
  log("Catch-Up already running; skipping overlapping invocation.")
173
197
  return
174
198
 
199
+ _heal_personal_schedules()
175
200
  state = load_state()
176
201
  tasks = catchup_candidates()
177
202