nexo-brain 7.30.28 → 7.30.30

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": "7.30.28",
3
+ "version": "7.30.29",
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
@@ -3403,6 +3403,20 @@ async function runSetup() {
3403
3403
  if (fs.existsSync(rulesSrc)) {
3404
3404
  copyDirRec(rulesSrc, rulesDest);
3405
3405
  log(" Rules updated.");
3406
+ const rulesSyncPython = findVenvPython(NEXO_HOME) || "python3";
3407
+ const rulesSync = spawnSync(rulesSyncPython, [
3408
+ "-c",
3409
+ "import os,sys; sys.path.insert(0, os.environ['NEXO_CODE']); from plugins.core_rules import _sync_rules_from_json; print(_sync_rules_from_json().get('active_total', 0))"
3410
+ ], {
3411
+ cwd: srcDir,
3412
+ env: { ...process.env, NEXO_HOME, NEXO_CODE: srcDir, PYTHONPATH: srcDir },
3413
+ encoding: "utf8",
3414
+ timeout: 30000,
3415
+ });
3416
+ if (rulesSync.status !== 0) {
3417
+ throw new Error(`Core rules registry sync failed: ${rulesSync.stderr || rulesSync.stdout || "unknown error"}`);
3418
+ }
3419
+ log(` Core rules registry synced (${String(rulesSync.stdout || "").trim() || "0"} active).`);
3406
3420
  }
3407
3421
 
3408
3422
  // Update crons (manifest.json + sync.py — needed by catchup & watchdog)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.28",
3
+ "version": "7.30.30",
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",
@@ -2593,6 +2593,7 @@ def _run_db_migrations() -> bool:
2593
2593
  applied = run_migrations(conn)
2594
2594
  if applied > 0:
2595
2595
  _log(f"Applied {applied} DB migration(s)")
2596
+ _sync_core_rules_registry()
2596
2597
  # Plan Consolidado F1 — one-shot legacy email config migration.
2597
2598
  # After m46 adds the table, operators installed pre-v6.4.0 still
2598
2599
  # keep their data inside ~/.nexo/nexo-email/config.json. If the
@@ -2620,6 +2621,21 @@ def _run_db_migrations() -> bool:
2620
2621
  return False
2621
2622
 
2622
2623
 
2624
+ def _sync_core_rules_registry() -> None:
2625
+ """Keep packaged product-core rules installed in the Brain DB."""
2626
+ try:
2627
+ from plugins.core_rules import _sync_rules_from_json
2628
+ result = _sync_rules_from_json()
2629
+ if result.get("status") == "applied":
2630
+ _log(
2631
+ "Core rules registry synced: "
2632
+ f"v{result.get('version_from')} -> v{result.get('version_to')} "
2633
+ f"({result.get('active_total')} active)"
2634
+ )
2635
+ except Exception as exc:
2636
+ raise RuntimeError(f"core rules registry sync failed: {exc}") from exc
2637
+
2638
+
2623
2639
  def _maybe_migrate_legacy_email_config() -> None:
2624
2640
  """F1 auto-migrator — idempotent. Runs the helper script the first
2625
2641
  time after v6.4.0 lands on an existing runtime."""
package/src/cli.py CHANGED
@@ -1287,17 +1287,27 @@ def _agents_set_schedule(args):
1287
1287
 
1288
1288
  interval_seconds = None
1289
1289
  daily_at = None
1290
+ schedule_freq = None
1291
+ schedule_at = None
1292
+ schedule_day = None
1290
1293
  if getattr(args, "every_minutes", None) is not None:
1291
1294
  interval_seconds = int(args.every_minutes) * 60
1292
1295
  elif getattr(args, "every_seconds", None) is not None:
1293
1296
  interval_seconds = int(args.every_seconds)
1294
1297
  elif getattr(args, "daily_at", None):
1295
1298
  daily_at = str(args.daily_at).strip()
1299
+ elif getattr(args, "schedule_freq", None):
1300
+ schedule_freq = str(args.schedule_freq).strip()
1301
+ schedule_at = str(getattr(args, "schedule_at", "") or "").strip()
1302
+ schedule_day = getattr(args, "schedule_day", None)
1296
1303
 
1297
1304
  result = set_agent_schedule(
1298
1305
  args.name,
1299
1306
  interval_seconds=interval_seconds,
1300
1307
  daily_at=daily_at,
1308
+ schedule_freq=schedule_freq,
1309
+ schedule_at=schedule_at,
1310
+ schedule_day=schedule_day,
1301
1311
  clear=bool(getattr(args, "reset", False)),
1302
1312
  )
1303
1313
  if args.json:
@@ -4160,7 +4170,10 @@ def main():
4160
4170
  agents_schedule_group.add_argument("--every-minutes", type=int, help="Run the agent every N minutes")
4161
4171
  agents_schedule_group.add_argument("--every-seconds", type=int, help="Run the agent every N seconds")
4162
4172
  agents_schedule_group.add_argument("--daily-at", type=str, help="Run the agent every day at HH:MM or HH:MM:weekday")
4173
+ agents_schedule_group.add_argument("--schedule-freq", choices=["daily", "weekly", "monthly", "every_n_days"], help="Run the agent on an anchored cadence")
4163
4174
  agents_schedule_group.add_argument("--reset", action="store_true", help="Clear the agent schedule")
4175
+ agents_schedule_p.add_argument("--schedule-at", type=str, help="Anchored schedule time HH:MM")
4176
+ agents_schedule_p.add_argument("--schedule-day", type=int, help="Weekday 0-6, month day 1-28, or every N days")
4164
4177
  agents_schedule_p.add_argument("--json", action="store_true", help="JSON output")
4165
4178
 
4166
4179
  agents_run_p = agents_sub.add_parser("run", help="Run an agent now")
package/src/db/_schema.py CHANGED
@@ -288,7 +288,13 @@ def _m15_core_rules_tables(conn):
288
288
  type TEXT NOT NULL DEFAULT 'advisory',
289
289
  added_in TEXT DEFAULT '',
290
290
  removed_in TEXT DEFAULT NULL,
291
- is_active INTEGER NOT NULL DEFAULT 1
291
+ is_active INTEGER NOT NULL DEFAULT 1,
292
+ source_artifact TEXT DEFAULT '',
293
+ source_anchor TEXT DEFAULT '',
294
+ content_hash TEXT DEFAULT '',
295
+ protected INTEGER NOT NULL DEFAULT 1,
296
+ severity TEXT DEFAULT 'critical',
297
+ replacement_rule_id TEXT DEFAULT NULL
292
298
  )
293
299
  """)
294
300
  conn.execute("""
@@ -3063,6 +3069,17 @@ def _m80_opportunity_orchestrator(conn):
3063
3069
  _migrate_add_index(conn, "idx_nexo_authorizations_scope", "nexo_action_authorizations", "scope, allowed_action_class")
3064
3070
 
3065
3071
 
3072
+ def _m81_core_rules_product_metadata(conn):
3073
+ """Add product-core provenance and protection metadata to core_rules."""
3074
+ _migrate_add_column(conn, "core_rules", "source_artifact", "TEXT DEFAULT ''")
3075
+ _migrate_add_column(conn, "core_rules", "source_anchor", "TEXT DEFAULT ''")
3076
+ _migrate_add_column(conn, "core_rules", "content_hash", "TEXT DEFAULT ''")
3077
+ _migrate_add_column(conn, "core_rules", "protected", "INTEGER NOT NULL DEFAULT 1")
3078
+ _migrate_add_column(conn, "core_rules", "severity", "TEXT DEFAULT 'critical'")
3079
+ _migrate_add_column(conn, "core_rules", "replacement_rule_id", "TEXT DEFAULT NULL")
3080
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_core_rules_protected ON core_rules(protected, is_active)")
3081
+
3082
+
3066
3083
  MIGRATIONS = [
3067
3084
  (1, "learnings_columns", _m1_learnings_columns),
3068
3085
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -3144,6 +3161,7 @@ MIGRATIONS = [
3144
3161
  (78, "operational_closure_plane", _m78_operational_closure_plane),
3145
3162
  (79, "operational_closure_links_readiness", _m79_operational_closure_links_readiness),
3146
3163
  (80, "opportunity_orchestrator", _m80_opportunity_orchestrator),
3164
+ (81, "core_rules_product_metadata", _m81_core_rules_product_metadata),
3147
3165
  ]
3148
3166
 
3149
3167
 
@@ -43,6 +43,8 @@ DEFAULT_MAX_JOB_ATTEMPTS = int(os.environ.get("NEXO_LOCAL_INDEX_MAX_JOB_ATTEMPTS
43
43
  DEFAULT_SQLITE_BUSY_RETRY_ATTEMPTS = int(os.environ.get("NEXO_LOCAL_CONTEXT_BUSY_RETRY_ATTEMPTS", "5") or "5")
44
44
  DEFAULT_SQLITE_BUSY_RETRY_DELAY_SECONDS = float(os.environ.get("NEXO_LOCAL_CONTEXT_BUSY_RETRY_DELAY_SECONDS", "0.35") or "0.35")
45
45
  DEFAULT_HYGIENE_QUICK_SCAN_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_HYGIENE_QUICK_SCAN_LIMIT", "5000") or "5000")
46
+ LOCAL_CONTEXT_DEFAULT_MAX_DB_BYTES = 60 * 1024 ** 3
47
+ LOCAL_CONTEXT_DEFAULT_MIN_FREE_BYTES = 5 * 1024 ** 3
46
48
  INITIAL_INDEX_COMPLETE_KEY = "initial_index_complete"
47
49
  INITIAL_INDEX_STARTED_AT_KEY = "initial_index_started_at"
48
50
  PERFORMANCE_PROFILE_KEY = "performance_profile"
@@ -1845,6 +1847,73 @@ def _is_paused_conn(conn) -> bool:
1845
1847
  return _get_state_conn(conn, "paused", "0") == "1"
1846
1848
 
1847
1849
 
1850
+ def _local_context_storage_bytes(db_path: Path | None = None) -> int:
1851
+ db = Path(db_path or local_context_db_path())
1852
+ total = 0
1853
+ for candidate in (db, Path(str(db) + "-wal"), Path(str(db) + "-shm")):
1854
+ try:
1855
+ total += int(candidate.stat().st_size)
1856
+ except OSError:
1857
+ pass
1858
+ return total
1859
+
1860
+
1861
+ def _local_context_disk_budget() -> dict:
1862
+ db_path = Path(local_context_db_path())
1863
+ max_bytes = paths.parse_size_bytes(
1864
+ os.environ.get("NEXO_LOCAL_CONTEXT_MAX_DB_BYTES"),
1865
+ default=LOCAL_CONTEXT_DEFAULT_MAX_DB_BYTES,
1866
+ )
1867
+ min_free_bytes = paths.parse_size_bytes(
1868
+ os.environ.get("NEXO_LOCAL_CONTEXT_MIN_FREE_BYTES"),
1869
+ default=LOCAL_CONTEXT_DEFAULT_MIN_FREE_BYTES,
1870
+ )
1871
+ db_bytes = _local_context_storage_bytes(db_path)
1872
+ try:
1873
+ probe = db_path.parent if db_path.parent.exists() else paths.memory_dir()
1874
+ free_bytes = int(shutil.disk_usage(str(probe)).free)
1875
+ except Exception:
1876
+ free_bytes = None
1877
+
1878
+ reason = ""
1879
+ if max_bytes > 0 and db_bytes > max_bytes:
1880
+ reason = "local_context_db_too_large"
1881
+ elif min_free_bytes > 0 and free_bytes is not None and free_bytes < min_free_bytes:
1882
+ reason = "disk_free_below_floor"
1883
+ return {
1884
+ "ok": not reason,
1885
+ "paused": bool(reason),
1886
+ "reason": reason,
1887
+ "db_path": str(db_path),
1888
+ "db_bytes": db_bytes,
1889
+ "max_bytes": max_bytes,
1890
+ "free_bytes": free_bytes,
1891
+ "min_free_bytes": min_free_bytes,
1892
+ }
1893
+
1894
+
1895
+ def enforce_local_context_disk_budget() -> dict:
1896
+ budget = _local_context_disk_budget()
1897
+ if budget["ok"]:
1898
+ return budget
1899
+ try:
1900
+ _set_state("paused", "1")
1901
+ _set_state("pause_reason", str(budget["reason"]))
1902
+ log_event(
1903
+ "warn",
1904
+ "index_paused_disk_budget",
1905
+ "Local memory indexing paused to protect disk space",
1906
+ reason=budget["reason"],
1907
+ db_bytes=budget["db_bytes"],
1908
+ max_bytes=budget["max_bytes"],
1909
+ free_bytes=budget["free_bytes"],
1910
+ min_free_bytes=budget["min_free_bytes"],
1911
+ )
1912
+ except Exception as exc:
1913
+ budget["pause_error"] = type(exc).__name__
1914
+ return budget
1915
+
1916
+
1848
1917
  def _allow_explicit_blocked_root(path: str) -> bool:
1849
1918
  # Test and controlled diagnostics may explicitly index a temporary fixture
1850
1919
  # root while production root discovery still skips temp/system trees.
@@ -3332,6 +3401,33 @@ def run_once(
3332
3401
  live_dir_limit: int | None = None,
3333
3402
  live_file_limit: int | None = None,
3334
3403
  ) -> dict:
3404
+ disk_budget = enforce_local_context_disk_budget()
3405
+ if disk_budget.get("paused"):
3406
+ config = performance_config()
3407
+ return {
3408
+ "ok": True,
3409
+ "paused": True,
3410
+ "disk_budget": disk_budget,
3411
+ "initial_scan": {
3412
+ "complete": False,
3413
+ "mode": "paused",
3414
+ "pending_roots": 0,
3415
+ "total_roots": 0,
3416
+ "checkpoint_count": 0,
3417
+ },
3418
+ "initial_index_complete": False,
3419
+ "live": {"ok": True, "paused": True, "assets": {}, "dirs": {}},
3420
+ "scan": {"ok": True, "paused": True, "roots": 0, "seen": 0, "changed": 0, "errors": 0, "partial": False},
3421
+ "jobs": {"ok": True, "paused": True, "processed": 0, "failed": 0},
3422
+ "performance": {
3423
+ "profile": config["profile"],
3424
+ "scan_limit": 0,
3425
+ "process_limit": 0,
3426
+ "live_asset_limit": 0,
3427
+ "live_dir_limit": 0,
3428
+ "live_file_limit": 0,
3429
+ },
3430
+ }
3335
3431
  if _get_state("privacy_hygiene_v2", "0") != "1":
3336
3432
  local_index_privacy_hygiene(fix=True)
3337
3433
  _set_state("privacy_hygiene_v2", "1")
@@ -3832,6 +3928,21 @@ def _status_from_conn(conn, *, readonly: bool = False) -> dict:
3832
3928
  service["state"] = "paused" if paused else ("attention" if problem else ("idle" if active_jobs == 0 and initial_index_complete else "indexing"))
3833
3929
  performance = performance_config(conn=conn)
3834
3930
  problems = _problem_rows(conn)
3931
+ disk_budget = _local_context_disk_budget()
3932
+ if not disk_budget["ok"]:
3933
+ problems.insert(0, {
3934
+ "user_message": "Local memory indexing is paused to protect disk space",
3935
+ "message_key": "local_context.disk_budget.paused",
3936
+ "recommended_action": "Review local memory size and free disk space",
3937
+ "recommended_action_key": "local_context.disk_budget.review",
3938
+ "technical_detail": json_dumps(disk_budget),
3939
+ "support_code": "LOCAL_CONTEXT_DISK_BUDGET",
3940
+ "severity": "warning",
3941
+ "retryable": True,
3942
+ "path": disk_budget["db_path"],
3943
+ "phase": "storage",
3944
+ "created_at": now(),
3945
+ })
3835
3946
  if problem:
3836
3947
  problems.insert(0, {
3837
3948
  "user_message": problem["user_message"],
@@ -3881,6 +3992,7 @@ def _status_from_conn(conn, *, readonly: bool = False) -> dict:
3881
3992
  "performance_profile": performance["profile"],
3882
3993
  },
3883
3994
  "performance": performance,
3995
+ "disk_budget": disk_budget,
3884
3996
  "initial_scan": initial_scan,
3885
3997
  "initial_index_complete": bool(initial_index_complete),
3886
3998
  "volumes": volumes,
package/src/paths.py CHANGED
@@ -515,8 +515,8 @@ def aggressive_runtime_backup_prune(
515
515
  ) -> dict:
516
516
  """Escalate NEXO-owned backup pruning before any user-facing disk alert.
517
517
 
518
- Escalation never targets protected business/hourly backup classes; the
519
- pruner enforces that policy.
518
+ Escalation never targets protected business/weekly restore classes. Hourly
519
+ DB dumps are pruned only down to their configured restore floor.
520
520
  """
521
521
  root = Path(backups_root or backups_dir())
522
522
  floor = int(min_free_bytes if min_free_bytes is not None else backup_min_free_bytes())
@@ -1,5 +1,6 @@
1
1
  """Core Rules plugin — query and manage versioned behavioral rules."""
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  import os
5
6
 
@@ -9,54 +10,167 @@ def _get_db():
9
10
  return get_db()
10
11
 
11
12
 
12
- def _seed_if_empty():
13
- """Seed rules from JSON if table is empty (first run after migration)."""
14
- import sys
15
- conn = _get_db()
16
- try:
17
- count = conn.execute("SELECT COUNT(*) FROM core_rules WHERE is_active = 1").fetchone()[0]
18
- except Exception:
19
- # Table doesn't exist yet — create it
20
- conn.execute("""CREATE TABLE IF NOT EXISTS core_rules (
21
- id TEXT PRIMARY KEY, category TEXT NOT NULL, rule TEXT NOT NULL,
22
- why TEXT NOT NULL, importance INTEGER NOT NULL DEFAULT 3,
23
- type TEXT NOT NULL DEFAULT 'advisory', added_in TEXT DEFAULT '',
24
- removed_in TEXT DEFAULT NULL, is_active INTEGER NOT NULL DEFAULT 1)""")
25
- conn.execute("""CREATE TABLE IF NOT EXISTS core_rules_version (
26
- id INTEGER PRIMARY KEY, version TEXT NOT NULL, updated_at TEXT NOT NULL)""")
27
- conn.execute("INSERT OR IGNORE INTO core_rules_version (id, version, updated_at) VALUES (1, '0.0.0', datetime('now'))")
28
- conn.commit()
29
- count = 0
30
- if count > 0:
31
- return
13
+ def _rules_file_path() -> str:
14
+ return os.path.join(
15
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
16
+ "rules",
17
+ "core-rules.json",
18
+ )
32
19
 
33
- rules_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
34
- "rules", "core-rules.json")
35
- if not os.path.exists(rules_file):
36
- print(f"[core_rules] WARNING: {rules_file} not found, skipping seed", file=sys.stderr)
37
- return
38
20
 
21
+ def _load_rules_data() -> dict:
22
+ with open(_rules_file_path()) as f:
23
+ return json.load(f)
24
+
25
+
26
+ def _rule_hash(rule: dict, category: str) -> str:
27
+ payload = {
28
+ "category": category,
29
+ "id": rule.get("id", ""),
30
+ "rule": rule.get("rule", ""),
31
+ "why": rule.get("why", ""),
32
+ "importance": rule.get("importance", 0),
33
+ "type": rule.get("type", ""),
34
+ "source_artifact": rule.get("source_artifact", ""),
35
+ "source_anchor": rule.get("source_anchor", ""),
36
+ }
37
+ raw = json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
38
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
39
+
40
+
41
+ def _ensure_schema(conn):
42
+ conn.execute("""CREATE TABLE IF NOT EXISTS core_rules (
43
+ id TEXT PRIMARY KEY, category TEXT NOT NULL, rule TEXT NOT NULL,
44
+ why TEXT NOT NULL, importance INTEGER NOT NULL DEFAULT 3,
45
+ type TEXT NOT NULL DEFAULT 'advisory', added_in TEXT DEFAULT '',
46
+ removed_in TEXT DEFAULT NULL, is_active INTEGER NOT NULL DEFAULT 1,
47
+ source_artifact TEXT DEFAULT '', source_anchor TEXT DEFAULT '',
48
+ content_hash TEXT DEFAULT '', protected INTEGER NOT NULL DEFAULT 1,
49
+ severity TEXT DEFAULT 'critical', replacement_rule_id TEXT DEFAULT NULL)""")
50
+ conn.execute("""CREATE TABLE IF NOT EXISTS core_rules_version (
51
+ id INTEGER PRIMARY KEY, version TEXT NOT NULL, updated_at TEXT NOT NULL)""")
52
+ for column, ddl in (
53
+ ("source_artifact", "TEXT DEFAULT ''"),
54
+ ("source_anchor", "TEXT DEFAULT ''"),
55
+ ("content_hash", "TEXT DEFAULT ''"),
56
+ ("protected", "INTEGER NOT NULL DEFAULT 1"),
57
+ ("severity", "TEXT DEFAULT 'critical'"),
58
+ ("replacement_rule_id", "TEXT DEFAULT NULL"),
59
+ ):
60
+ try:
61
+ existing = {row[1] for row in conn.execute("PRAGMA table_info(core_rules)").fetchall()}
62
+ if column not in existing:
63
+ conn.execute(f"ALTER TABLE core_rules ADD COLUMN {column} {ddl}")
64
+ except Exception:
65
+ pass
66
+ conn.execute("INSERT OR IGNORE INTO core_rules_version (id, version, updated_at) VALUES (1, '0.0.0', datetime('now'))")
67
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_core_rules_category ON core_rules(category)")
68
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_core_rules_active ON core_rules(is_active)")
69
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_core_rules_protected ON core_rules(protected, is_active)")
70
+
71
+
72
+ def _flatten_rules(data: dict) -> dict[str, dict]:
73
+ version = data["_meta"]["version"]
74
+ rules = {}
75
+ for cat_key, cat in data["categories"].items():
76
+ for rule in cat["rules"]:
77
+ severity = rule.get("severity") or ("critical" if rule.get("type") == "blocking" and int(rule.get("importance") or 0) >= 5 else "high")
78
+ rules[rule["id"]] = {
79
+ **rule,
80
+ "category": cat_key,
81
+ "added_in": rule.get("added_in", version),
82
+ "content_hash": _rule_hash(rule, cat_key),
83
+ "protected": 0 if rule.get("protected") is False else 1,
84
+ "severity": severity,
85
+ "source_artifact": rule.get("source_artifact", "core-rules.json"),
86
+ "source_anchor": rule.get("source_anchor", rule["id"]),
87
+ "replacement_rule_id": rule.get("replacement_rule_id"),
88
+ }
89
+ return rules
90
+
91
+
92
+ def _sync_rules_from_json(conn=None, dry_run: bool = False) -> dict:
93
+ conn = conn or _get_db()
94
+ _ensure_schema(conn)
95
+ data = _load_rules_data()
96
+ new_version = data["_meta"]["version"]
97
+ json_rules = _flatten_rules(data)
98
+
99
+ current_version_row = conn.execute("SELECT version FROM core_rules_version WHERE id = 1").fetchone()
100
+ current_version = current_version_row[0] if current_version_row else "0.0.0"
101
+ db_rows = {
102
+ row["id"]: dict(row)
103
+ for row in conn.execute("SELECT * FROM core_rules WHERE is_active = 1").fetchall()
104
+ }
105
+
106
+ added = set(json_rules) - set(db_rows)
107
+ removed = set(db_rows) - set(json_rules)
108
+ changed = {
109
+ rid
110
+ for rid in set(json_rules) & set(db_rows)
111
+ if (db_rows[rid].get("content_hash") or "") != json_rules[rid]["content_hash"]
112
+ or current_version != new_version
113
+ }
114
+
115
+ result = {
116
+ "version_from": current_version,
117
+ "version_to": new_version,
118
+ "added": sorted(added),
119
+ "removed": sorted(removed),
120
+ "changed": sorted(changed),
121
+ "active_total": len(json_rules),
122
+ "dry_run": dry_run,
123
+ }
124
+ if dry_run:
125
+ return result
126
+
127
+ for rid in sorted(added | changed):
128
+ r = json_rules[rid]
129
+ conn.execute(
130
+ """INSERT OR REPLACE INTO core_rules
131
+ (id, category, rule, why, importance, type, added_in, removed_in, is_active,
132
+ source_artifact, source_anchor, content_hash, protected, severity, replacement_rule_id)
133
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?, ?, ?, ?)""",
134
+ (
135
+ r["id"],
136
+ r["category"],
137
+ r["rule"],
138
+ r["why"],
139
+ r["importance"],
140
+ r["type"],
141
+ r["added_in"],
142
+ r["source_artifact"],
143
+ r["source_anchor"],
144
+ r["content_hash"],
145
+ r["protected"],
146
+ r["severity"],
147
+ r["replacement_rule_id"],
148
+ ),
149
+ )
150
+
151
+ for rid in sorted(removed):
152
+ replacement = db_rows.get(rid, {}).get("replacement_rule_id")
153
+ conn.execute(
154
+ "UPDATE core_rules SET is_active = 0, removed_in = ?, replacement_rule_id = COALESCE(?, replacement_rule_id) WHERE id = ?",
155
+ (new_version, replacement, rid),
156
+ )
157
+
158
+ conn.execute("UPDATE core_rules_version SET version = ?, updated_at = datetime('now') WHERE id = 1", (new_version,))
159
+ conn.commit()
160
+ result["status"] = "up_to_date" if not (added or removed or changed or current_version != new_version) else "applied"
161
+ return result
162
+
163
+
164
+ def _sync_if_needed():
165
+ """Keep installed DB rules aligned with packaged product-core JSON."""
166
+ import sys
167
+ if not os.path.exists(_rules_file_path()):
168
+ print(f"[core_rules] WARNING: {_rules_file_path()} not found, skipping sync", file=sys.stderr)
169
+ return
39
170
  try:
40
- with open(rules_file) as f:
41
- data = json.load(f)
42
-
43
- version = data["_meta"]["version"]
44
- loaded = 0
45
- for cat_key, cat in data["categories"].items():
46
- for rule in cat["rules"]:
47
- conn.execute(
48
- """INSERT OR REPLACE INTO core_rules (id, category, rule, why, importance, type, added_in)
49
- VALUES (?, ?, ?, ?, ?, ?, ?)""",
50
- (rule["id"], cat_key, rule["rule"], rule["why"],
51
- rule["importance"], rule["type"], rule.get("added_in", version))
52
- )
53
- loaded += 1
54
-
55
- conn.execute("UPDATE core_rules_version SET version = ?, updated_at = datetime('now') WHERE id = 1", (version,))
56
- conn.commit()
57
- print(f"[core_rules] Seeded {loaded} rules (v{version})", file=sys.stderr)
171
+ _sync_rules_from_json()
58
172
  except Exception as e:
59
- print(f"[core_rules] ERROR seeding rules: {e}", file=sys.stderr)
173
+ print(f"[core_rules] ERROR syncing rules: {e}", file=sys.stderr)
60
174
 
61
175
 
62
176
  def handle_rules_check(area: str = "", importance_min: int = 0) -> str:
@@ -70,21 +184,24 @@ def handle_rules_check(area: str = "", importance_min: int = 0) -> str:
70
184
  Maps to categories: code→execution+integrity, delegation→delegation, etc.
71
185
  importance_min: Minimum importance level (1-5, default 0 = all rules)
72
186
  """
73
- _seed_if_empty()
187
+ _sync_if_needed()
74
188
  conn = _get_db()
75
189
 
76
190
  area_to_categories = {
77
- "code": ("integrity", "execution"),
78
- "edit": ("integrity", "execution"),
191
+ "code": ("integrity", "execution", "product_core", "bootstrap_contract"),
192
+ "edit": ("integrity", "execution", "product_core", "bootstrap_contract"),
79
193
  "delegation": ("delegation",),
80
194
  "delegate": ("delegation",),
81
195
  "subagent": ("delegation",),
82
- "communication": ("communication",),
83
- "respond": ("communication",),
84
- "memory": ("memory",),
85
- "learn": ("memory",),
86
- "proactivity": ("proactivity",),
87
- "protect": ("proactivity",),
196
+ "communication": ("communication", "product_core"),
197
+ "respond": ("communication", "product_core"),
198
+ "memory": ("memory", "product_core", "bootstrap_contract"),
199
+ "learn": ("memory", "product_core"),
200
+ "proactivity": ("proactivity", "product_core"),
201
+ "protect": ("proactivity", "product_core"),
202
+ "support": ("product_core",),
203
+ "capability": ("product_core",),
204
+ "bootstrap": ("bootstrap_contract",),
88
205
  }
89
206
 
90
207
  where = "WHERE is_active = 1"
@@ -146,7 +263,7 @@ def handle_rules_list(
146
263
  limit: int = 0,
147
264
  ) -> str:
148
265
  """List all core rules with their status, grouped by category."""
149
- _seed_if_empty()
266
+ _sync_if_needed()
150
267
  conn = _get_db()
151
268
 
152
269
  ver = conn.execute("SELECT version FROM core_rules_version WHERE id = 1").fetchone()
@@ -202,75 +319,22 @@ def handle_rules_migrate(dry_run: bool = False) -> str:
202
319
  Args:
203
320
  dry_run: If True, show what would change without applying
204
321
  """
205
- conn = _get_db()
206
- rules_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
207
- "rules", "core-rules.json")
208
- if not os.path.exists(rules_file):
322
+ if not os.path.exists(_rules_file_path()):
209
323
  return "ERROR: core-rules.json not found"
210
-
211
- with open(rules_file) as f:
212
- data = json.load(f)
213
-
214
- new_version = data["_meta"]["version"]
215
- ver = conn.execute("SELECT version FROM core_rules_version WHERE id = 1").fetchone()
216
- current_version = ver[0] if ver else "0.0.0"
217
-
218
- # Collect all rule IDs from JSON
219
- json_ids = set()
220
- json_rules = {}
221
- for cat_key, cat in data["categories"].items():
222
- for rule in cat["rules"]:
223
- json_ids.add(rule["id"])
224
- json_rules[rule["id"]] = {**rule, "category": cat_key}
225
-
226
- # Collect active IDs from DB
227
- db_ids = set()
228
- for r in conn.execute("SELECT id FROM core_rules WHERE is_active = 1").fetchall():
229
- db_ids.add(r[0])
230
-
231
- added = json_ids - db_ids
232
- removed = db_ids - json_ids
233
- unchanged = json_ids & db_ids
324
+ result = _sync_rules_from_json(dry_run=dry_run)
234
325
 
235
326
  lines = [
236
- f"RULES MIGRATION: v{current_version} → v{new_version}",
237
- f" Added: {len(added)} — {', '.join(sorted(added)) if added else 'none'}",
238
- f" Removed: {len(removed)} — {', '.join(sorted(removed)) if removed else 'none'}",
239
- f" Unchanged: {len(unchanged)}",
327
+ f"RULES MIGRATION: v{result['version_from']} → v{result['version_to']}",
328
+ f" Added: {len(result['added'])} — {', '.join(result['added']) if result['added'] else 'none'}",
329
+ f" Removed: {len(result['removed'])} — {', '.join(result['removed']) if result['removed'] else 'none'}",
330
+ f" Changed: {len(result['changed'])} — {', '.join(result['changed']) if result['changed'] else 'none'}",
331
+ f" Active total: {result['active_total']}",
240
332
  ]
241
333
 
242
334
  if dry_run:
243
335
  lines.append(" Mode: DRY RUN (no changes applied)")
244
- return "\n".join(lines)
245
-
246
- # Apply additions
247
- for rid in added:
248
- r = json_rules[rid]
249
- conn.execute(
250
- """INSERT OR REPLACE INTO core_rules (id, category, rule, why, importance, type, added_in, is_active)
251
- VALUES (?, ?, ?, ?, ?, ?, ?, 1)""",
252
- (r["id"], r["category"], r["rule"], r["why"], r["importance"], r["type"], r.get("added_in", new_version))
253
- )
254
-
255
- # Apply removals (soft delete)
256
- for rid in removed:
257
- conn.execute(
258
- "UPDATE core_rules SET is_active = 0, removed_in = ? WHERE id = ?",
259
- (new_version, rid)
260
- )
261
-
262
- # Update existing rules (content might have changed)
263
- for rid in unchanged:
264
- r = json_rules[rid]
265
- conn.execute(
266
- "UPDATE core_rules SET rule = ?, why = ?, importance = ?, type = ?, category = ? WHERE id = ?",
267
- (r["rule"], r["why"], r["importance"], r["type"], r["category"], rid)
268
- )
269
-
270
- conn.execute("UPDATE core_rules_version SET version = ?, updated_at = datetime('now') WHERE id = 1", (new_version,))
271
- conn.commit()
272
-
273
- lines.append(" Status: APPLIED")
336
+ else:
337
+ lines.append(f" Status: {str(result.get('status') or 'APPLIED').upper()}")
274
338
  return "\n".join(lines)
275
339
 
276
340