ltcai 11.0.1 → 11.2.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.
Files changed (140) hide show
  1. package/README.md +55 -43
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/PERFORMANCE.md +71 -18
  10. package/docs/TRUST_MODEL.md +1 -1
  11. package/docs/WHY_LATTICE.md +1 -1
  12. package/docs/architecture.md +6 -2
  13. package/docs/kg-schema.md +1 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/embeddings.py +12 -37
  16. package/lattice_brain/gates.py +125 -0
  17. package/lattice_brain/graph/discovery_index.py +30 -32
  18. package/lattice_brain/graph/fusion.py +35 -4
  19. package/lattice_brain/graph/image_vectors.py +230 -0
  20. package/lattice_brain/graph/ingest.py +11 -5
  21. package/lattice_brain/graph/projection.py +66 -8
  22. package/lattice_brain/graph/provenance.py +27 -2
  23. package/lattice_brain/graph/retrieval.py +113 -2
  24. package/lattice_brain/graph/retrieval_docgen.py +6 -6
  25. package/lattice_brain/graph/schema.py +18 -0
  26. package/lattice_brain/graph/store.py +9 -0
  27. package/lattice_brain/graph/vector_index/selector.py +32 -2
  28. package/lattice_brain/ingestion.py +363 -10
  29. package/lattice_brain/multimodal.py +1258 -0
  30. package/lattice_brain/portability.py +169 -32
  31. package/lattice_brain/runtime/multi_agent.py +1 -1
  32. package/lattice_brain/sealed_box.py +244 -0
  33. package/lattice_brain/self_model.py +77 -22
  34. package/lattice_brain/synthesis.py +24 -1
  35. package/latticeai/__init__.py +1 -1
  36. package/latticeai/api/brain_intelligence.py +4 -0
  37. package/latticeai/api/chat.py +11 -0
  38. package/latticeai/api/chat_helpers.py +16 -3
  39. package/latticeai/api/chat_hybrid.py +32 -1
  40. package/latticeai/api/features.py +70 -0
  41. package/latticeai/api/local_files.py +102 -0
  42. package/latticeai/api/memory.py +128 -1
  43. package/latticeai/api/portability.py +39 -4
  44. package/latticeai/api/review_queue.py +126 -0
  45. package/latticeai/api/search.py +16 -2
  46. package/latticeai/core/agent.py +59 -2
  47. package/latticeai/core/agent_prompts.py +66 -0
  48. package/latticeai/core/config.py +4 -1
  49. package/latticeai/core/context_builder.py +98 -11
  50. package/latticeai/core/embedding_providers.py +528 -0
  51. package/latticeai/core/legacy_compatibility.py +1 -1
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/messages.py +180 -0
  54. package/latticeai/core/model_compat.py +73 -2
  55. package/latticeai/core/workspace_os.py +43 -0
  56. package/latticeai/core/workspace_os_constants.py +1 -1
  57. package/latticeai/core/workspace_reorganization.py +335 -0
  58. package/latticeai/models/model_providers.py +12 -4
  59. package/latticeai/runtime/build_phases.py +33 -2
  60. package/latticeai/runtime/chat_wiring.py +4 -0
  61. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  62. package/latticeai/runtime/persistence_runtime.py +41 -4
  63. package/latticeai/runtime/router_registration.py +11 -0
  64. package/latticeai/runtime/runtime_context.py +1 -0
  65. package/latticeai/services/app_context.py +8 -0
  66. package/latticeai/services/architecture_readiness.py +1 -1
  67. package/latticeai/services/automation_intelligence.py +22 -2
  68. package/latticeai/services/brain_intelligence.py +123 -7
  69. package/latticeai/services/change_proposals.py +50 -10
  70. package/latticeai/services/command_center.py +10 -4
  71. package/latticeai/services/feature_toggles.py +502 -0
  72. package/latticeai/services/folder_watch.py +122 -1
  73. package/latticeai/services/hybrid_chat.py +56 -5
  74. package/latticeai/services/interop_bridges.py +978 -0
  75. package/latticeai/services/memory_service.py +34 -0
  76. package/latticeai/services/model_capability_registry.py +434 -261
  77. package/latticeai/services/model_catalog.py +95 -61
  78. package/latticeai/services/model_recommendation.py +18 -11
  79. package/latticeai/services/model_runtime.py +1 -1
  80. package/latticeai/services/multimodal_ports.py +112 -0
  81. package/latticeai/services/obsidian_bridge.py +16 -25
  82. package/latticeai/services/product_readiness.py +1 -1
  83. package/latticeai/services/search_service.py +149 -2
  84. package/latticeai/services/self_model_service.py +171 -0
  85. package/latticeai/services/tool_dispatch.py +4 -0
  86. package/latticeai/services/voice_capture.py +27 -1
  87. package/latticeai/setup/auto_setup.py +27 -30
  88. package/latticeai/setup/wizard.py +77 -44
  89. package/package.json +1 -1
  90. package/scripts/check_current_release_docs.mjs +1 -1
  91. package/scripts/check_server_i18n.mjs +1 -0
  92. package/scripts/release_screen_claims.json +22 -0
  93. package/scripts/verify_hf_model_registry.py +253 -218
  94. package/src-tauri/Cargo.lock +1 -1
  95. package/src-tauri/Cargo.toml +1 -1
  96. package/src-tauri/tauri.conf.json +1 -1
  97. package/static/app/asset-manifest.json +37 -37
  98. package/static/app/assets/{Act-D4zSxFR-.js → Act-AWf0SAKp.js} +1 -1
  99. package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D0u8Tiyj.js} +1 -1
  100. package/static/app/assets/{Brain-C2EqQg74.js → Brain-tuhI4sOC.js} +1 -1
  101. package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
  102. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +1 -0
  103. package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CqOSzyPr.js} +1 -1
  104. package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DC0Bzh-I.js} +1 -1
  105. package/static/app/assets/{Library-BN0HYOfc.js → Library-CX-bbhmK.js} +1 -1
  106. package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-DBwhto14.js} +1 -1
  107. package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-BHA2cfKI.js} +1 -1
  108. package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-BUhCKRNM.js} +1 -1
  109. package/static/app/assets/{System-BElUcSSw.js → System-Bu2t5hn1.js} +1 -1
  110. package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
  111. package/static/app/assets/{bot--qYHMtkP.js → bot-Cia42c2h.js} +1 -1
  112. package/static/app/assets/brain-DJMoqrwx.js +1 -0
  113. package/static/app/assets/{button-51Z3rsuv.js → button-2j2Ijzgq.js} +1 -1
  114. package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-BEFeWpVW.js} +1 -1
  115. package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-ujXMcHxl.js} +1 -1
  116. package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-k4awryFq.js} +1 -1
  117. package/static/app/assets/{download-G-2olkWz.js → download-DFbLJ_ig.js} +1 -1
  118. package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-7y_b6xkM.js} +1 -1
  119. package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-Bidh02Kr.js} +1 -1
  120. package/static/app/assets/{index-D7Rr-J2Y.js → index-BpYkzcVm.js} +3 -3
  121. package/static/app/assets/index-DwDl9-8Y.css +2 -0
  122. package/static/app/assets/{input-D4w_BZWl.js → input-DSlJJxRs.js} +1 -1
  123. package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-Bpb83Hx9.js} +1 -1
  124. package/static/app/assets/{primitives-d0g9pvzS.js → primitives-BCx6TvfG.js} +1 -1
  125. package/static/app/assets/search-Cgy8cCFJ.js +1 -0
  126. package/static/app/assets/{share-2-NmD7e_oV.js → share-2-BH1M-WNi.js} +1 -1
  127. package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-BlKdBXcG.js} +1 -1
  128. package/static/app/assets/{textarea-BPAJDc-0.js → textarea-CCWbUfFB.js} +1 -1
  129. package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
  130. package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-CXQiwbVT.js} +1 -1
  131. package/static/app/assets/{utils-DG1_ExrP.js → utils-zqPZJxdx.js} +2 -2
  132. package/static/app/assets/{workspace-CWVf3gsI.js → workspace-DXTihhfU.js} +1 -1
  133. package/static/app/index.html +4 -4
  134. package/static/sw.js +1 -1
  135. package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
  136. package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
  137. package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
  138. package/static/app/assets/brain-DDCLjRqO.js +0 -1
  139. package/static/app/assets/index-CkzokZAj.css +0 -2
  140. package/static/app/assets/search-BLCYt75v.js +0 -1
@@ -42,6 +42,10 @@ def build_persistence_runtime(
42
42
  from latticeai.services.brain_intelligence import BrainIntelligenceService
43
43
  from latticeai.services.funnel_metrics import FunnelMetricsService
44
44
  from latticeai.services.memory_service import MemoryService
45
+ from latticeai.services.multimodal_ports import (
46
+ build_multimodal_ports,
47
+ multimodal_enabled,
48
+ )
45
49
  from latticeai.services.workspace_service import WorkspaceService
46
50
 
47
51
  realtime_bus = RealtimeBus()
@@ -80,21 +84,53 @@ def build_persistence_runtime(
80
84
  funnel_metrics = FunnelMetricsService(Path(data_dir) / "funnel_metrics.json")
81
85
 
82
86
  def _funnel_audit(action, detail, user):
83
- try:
84
- if action == "kg_ingest":
87
+ """Best-effort sinks for a landed ingest, then the real audit.
88
+
89
+ Both sinks are isolated separately: one failing must not cost the
90
+ other, and neither may cost the ingest that is already persisted.
91
+ """
92
+ if action == "kg_ingest":
93
+ try:
85
94
  funnel_metrics.record_ingest(
86
95
  duplicate=bool((detail or {}).get("duplicate"))
87
96
  )
88
- except Exception: # noqa: BLE001 — metrics must never break ingestion
89
- quiet()
97
+ except Exception: # noqa: BLE001 — metrics must never break ingestion
98
+ quiet()
99
+ # v11.1.0: the same seam drives synthesis. The pipeline audits an
100
+ # ingest only after the graph write landed, so ``status: "ok"`` is
101
+ # a fact about this call site rather than a guess; ``duplicate``
102
+ # travels in the detail and is what keeps a re-import from pushing
103
+ # the Brain toward a pass it has nothing new to say in.
104
+ try:
105
+ brain_intelligence.note_ingest(
106
+ {"status": "ok", **(detail or {})}, user_email=user
107
+ )
108
+ except Exception: # noqa: BLE001 — synthesis must never break ingestion
109
+ quiet()
90
110
  audit(action, detail, user)
91
111
 
112
+ # Multi-modal capture (v11.1.0): off unless the user turned it on, and
113
+ # even then only as capable as the models actually present. With nothing
114
+ # configured this resolves to an empty bundle without importing or loading
115
+ # anything.
116
+ multimodal_ports = build_multimodal_ports()
92
117
  ingestion_pipeline = IngestionPipeline(
93
118
  knowledge_graph,
94
119
  hooks=hooks_registry,
95
120
  enable_graph=enable_graph,
96
121
  audit=_funnel_audit,
122
+ allow_multimodal=multimodal_enabled(),
123
+ multimodal=multimodal_ports,
97
124
  )
125
+ # The local folder scanner reads images through the same ports, so a
126
+ # caption in the Evidence panel and a caption on a scanned screenshot come
127
+ # from one model or from none. A disabled/absent graph has nothing to
128
+ # attach them to, and a store that refuses new attributes is not a reason
129
+ # to fail startup.
130
+ try:
131
+ knowledge_graph.multimodal_ports = multimodal_ports
132
+ except AttributeError:
133
+ quiet()
98
134
  device_identity = DeviceIdentity(data_dir)
99
135
  kg_portability = KGPortabilityService(
100
136
  knowledge_graph=knowledge_graph,
@@ -116,6 +152,7 @@ def build_persistence_runtime(
116
152
  "BRAIN_INTELLIGENCE": brain_intelligence,
117
153
  "AUTOMATION_INTELLIGENCE": automation_intelligence,
118
154
  "INGESTION_PIPELINE": ingestion_pipeline,
155
+ "MULTIMODAL_PORTS": multimodal_ports,
119
156
  "DEVICE_IDENTITY": device_identity,
120
157
  "KG_PORTABILITY": kg_portability,
121
158
  "FUNNEL_METRICS": funnel_metrics,
@@ -675,4 +675,15 @@ def register_review_and_brain_tail_routers(
675
675
  append_audit_event=append_audit_event,
676
676
  knowledge_graph=knowledge_graph,
677
677
  )
678
+ # The opt-in switchboard. Mounted last on purpose: binding the gates to the
679
+ # service is what makes a stored preference beat an env var, and every
680
+ # module that owns one of those gates has been imported by now.
681
+ from latticeai.runtime.feature_toggle_wiring import register_features_router
682
+
683
+ register_features_router(
684
+ app,
685
+ require_user=require_user,
686
+ data_dir=data_dir,
687
+ append_audit_event=append_audit_event,
688
+ )
678
689
  return brain_network
@@ -144,6 +144,7 @@ class RuntimeContext:
144
144
  BRAIN_INTELLIGENCE: Any
145
145
  AUTOMATION_INTELLIGENCE: Any
146
146
  INGESTION_PIPELINE: Any
147
+ MULTIMODAL_PORTS: Any
147
148
  DEVICE_IDENTITY: Any
148
149
  KG_PORTABILITY: Any
149
150
  FUNNEL_METRICS: Any
@@ -76,6 +76,14 @@ class AppContext:
76
76
  workspace_graph: Optional[Callable[[], Any]] = None
77
77
  graph_stats: Optional[Callable[[], dict]] = None
78
78
 
79
+ # ── review center ─────────────────────────────────────────────────────
80
+ # ``ReviewQueueService``, reached through a provider like
81
+ # ``workspace_graph`` above: the queue is built in a later runtime phase
82
+ # than this context, so a value captured here would be ``None`` forever.
83
+ # Call it per request; ``None`` means no Review Center is wired (tests,
84
+ # headless helpers) and the callers stage nothing rather than writing.
85
+ review_queue: Optional[Callable[[], Any]] = None
86
+
79
87
  # ── workspace payload providers / skills ──────────────────────────────
80
88
  workspace_models: Optional[Callable[[], dict]] = None
81
89
  workspace_settings: Optional[Callable[[], dict]] = None
@@ -16,7 +16,7 @@ from typing import Any, Dict, List
16
16
 
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
- ARCHITECTURE_VERSION_TARGET = "11.0.1"
19
+ ARCHITECTURE_VERSION_TARGET = "11.2.0"
20
20
 
21
21
  PREFERRED_REFACTORING_ORDER = [
22
22
  "agent-runtime",
@@ -55,6 +55,13 @@ _SIGNATURE_SIMILARITY = 0.6
55
55
  _MIN_SUGGESTION_CONFIDENCE = 0.35
56
56
  _LOW_CONFIDENCE_THRESHOLD = 0.5
57
57
  _KG_GROUNDING_LIMIT = 5
58
+ #: Charged when the graph *was* consulted and found nothing related. v11.2.0
59
+ #: fixes a dead policy: the floor above was unreachable for recurring questions
60
+ #: (the cheapest possible question — asked twice, one phrasing, no recipe —
61
+ #: scored 0.475), so the suppression branch could only ever be exercised by a
62
+ #: test that moved the constant. Absence of grounding is real evidence, and
63
+ #: charging for it is what makes the floor mean something again.
64
+ _UNGROUNDED_PENALTY = 0.15
58
65
 
59
66
 
60
67
  def _question_confidence(
@@ -67,7 +74,13 @@ def _question_confidence(
67
74
 
68
75
  Evidence factors: how often the question repeats, how many distinct
69
76
  phrasings exist, whether it maps onto a known starter-recipe intent, and
70
- (when the graph is available) how many Brain nodes relate to it.
77
+ what the Brain knows about it.
78
+
79
+ That last factor is three-valued on purpose. ``None`` means the graph could
80
+ not be consulted — unknown, so nothing is added or taken away. A positive
81
+ count is supporting evidence. **Zero** is not the same as unknown: the
82
+ graph was asked, and it knows nothing about this question, which is a
83
+ reason to be less sure rather than a reason to be neutral.
71
84
  """
72
85
  score = 0.3 + 0.5 * min(1.0, (int(count) - 1) / 4)
73
86
  score += min(0.15, 0.05 * len(examples or []))
@@ -75,13 +88,16 @@ def _question_confidence(
75
88
  score += 0.15
76
89
  if kg_related:
77
90
  score += min(0.2, 0.05 * int(kg_related))
91
+ elif kg_related == 0:
92
+ score -= _UNGROUNDED_PENALTY
78
93
  factors = {
79
94
  "repeat_count": int(count),
80
95
  "distinct_examples": len(examples or []),
81
96
  "intent_match": bool(recipe_id),
82
97
  "kg_related_nodes": kg_related,
98
+ "kg_grounded": None if kg_related is None else bool(kg_related),
83
99
  }
84
- return round(min(1.0, score), 2), factors
100
+ return round(max(0.0, min(1.0, score)), 2), factors
85
101
 
86
102
 
87
103
  def _source_confidence(indexed: int, watch_enabled: bool) -> tuple:
@@ -357,6 +373,10 @@ class AutomationIntelligenceService:
357
373
  pattern["count"], pattern["examples"], recipe_id, kg_related
358
374
  )
359
375
  if confidence < _MIN_SUGGESTION_CONFIDENCE:
376
+ # Asked a couple of times, phrased one way, matching no recipe,
377
+ # and the Brain knows nothing about it — not enough to propose
378
+ # automating. Counted, so the surface can say how many were held
379
+ # back rather than silently showing fewer.
360
380
  suppressed_low_confidence += 1
361
381
  continue
362
382
  if recipe_id:
@@ -52,6 +52,21 @@ def _parse_ts(value: Any) -> Optional[datetime]:
52
52
  return parsed
53
53
 
54
54
 
55
+ def _no_graph_reason(graph_available: bool) -> str:
56
+ """Why a graph-derived health dimension has nothing to say.
57
+
58
+ Two different situations that both end in ``status: "unavailable"``: the
59
+ graph could not be read at all, and the graph read fine but holds nothing
60
+ yet. Telling them apart is the difference between "something is broken"
61
+ and "you have not saved anything yet".
62
+ """
63
+ return (
64
+ "no knowledge saved yet"
65
+ if graph_available
66
+ else "the knowledge graph could not be read"
67
+ )
68
+
69
+
55
70
  class BrainIntelligenceService:
56
71
  def __init__(
57
72
  self,
@@ -395,7 +410,11 @@ class BrainIntelligenceService:
395
410
  "stale_threshold_days": _STALE_DAYS,
396
411
  }
397
412
  else:
398
- dimensions["freshness"] = {"status": "unavailable", "score": None}
413
+ dimensions["freshness"] = {
414
+ "status": "unavailable",
415
+ "score": None,
416
+ "reason": _no_graph_reason(sample["available"]),
417
+ }
399
418
 
400
419
  # Connectivity — orphan nodes are knowledge the Brain cannot reason
401
420
  # across; a well-tended graph keeps them rare.
@@ -414,7 +433,11 @@ class BrainIntelligenceService:
414
433
  "edges": len(edges),
415
434
  }
416
435
  else:
417
- dimensions["connectivity"] = {"status": "unavailable", "score": None}
436
+ dimensions["connectivity"] = {
437
+ "status": "unavailable",
438
+ "score": None,
439
+ "reason": _no_graph_reason(sample["available"]),
440
+ }
418
441
 
419
442
  # Embedding coverage — semantic recall only works for indexed items.
420
443
  index_status: Dict[str, Any] = {}
@@ -425,7 +448,23 @@ class BrainIntelligenceService:
425
448
  LOGGER.exception("brain intelligence index status failed")
426
449
  index_status = {"error": str(exc)}
427
450
  scale = index_status.get("scale") or {}
428
- if "coverage_ratio" in scale:
451
+ indexable = scale.get("source_items", index_status.get("source_items"))
452
+ if "coverage_ratio" not in scale:
453
+ dimensions["embedding_coverage"] = {
454
+ "status": "unavailable",
455
+ "score": None,
456
+ "reason": "this knowledge store does not report vector index coverage",
457
+ }
458
+ elif indexable == 0:
459
+ # An empty index covers 100% of nothing. Scoring that as a perfect
460
+ # 100 is how a brand-new Brain used to grade itself "excellent"
461
+ # off its only measurable dimension (audit v11.2.0, Finding 3).
462
+ dimensions["embedding_coverage"] = {
463
+ "status": "unavailable",
464
+ "score": None,
465
+ "reason": "no indexable items yet",
466
+ }
467
+ else:
429
468
  dimensions["embedding_coverage"] = {
430
469
  "status": "ok",
431
470
  "score": round(float(scale["coverage_ratio"]) * 100),
@@ -433,8 +472,6 @@ class BrainIntelligenceService:
433
472
  "pending_items": scale.get("pending_items"),
434
473
  "needs_reindex": index_status.get("status") == "needs_reindex",
435
474
  }
436
- else:
437
- dimensions["embedding_coverage"] = {"status": "unavailable", "score": None}
438
475
 
439
476
  # Edge quality + contradiction pressure — reuses the quality layer.
440
477
  if sample["available"] and edges:
@@ -450,7 +487,15 @@ class BrainIntelligenceService:
450
487
  "contradiction_edges": len(contradiction_edges),
451
488
  }
452
489
  else:
453
- dimensions["consistency"] = {"status": "unavailable", "score": None}
490
+ dimensions["consistency"] = {
491
+ "status": "unavailable",
492
+ "score": None,
493
+ "reason": (
494
+ _no_graph_reason(sample["available"])
495
+ if not (sample["available"] and nodes)
496
+ else "no relationships recorded yet"
497
+ ),
498
+ }
454
499
 
455
500
  scores = [d["score"] for d in dimensions.values() if d.get("score") is not None]
456
501
  overall = round(sum(scores) / len(scores)) if scores else None
@@ -461,6 +506,29 @@ class BrainIntelligenceService:
461
506
  else "attention" if overall >= 50
462
507
  else "critical"
463
508
  )
509
+ # What the verdict rests on. A composite averages only what could be
510
+ # measured, so the count of measured dimensions is part of the answer
511
+ # rather than a footnote — and when nothing could be measured the
512
+ # report says so instead of leaving a bare null (the 9.9.7 rule: a
513
+ # "—" always states why).
514
+ unmeasured = sorted(
515
+ name for name, dim in dimensions.items() if dim.get("score") is None
516
+ )
517
+ coverage: Dict[str, Any] = {
518
+ "measured": len(scores),
519
+ "total": len(dimensions),
520
+ "unavailable": unmeasured,
521
+ "partial": bool(unmeasured),
522
+ }
523
+ reason: Optional[str] = None
524
+ if overall is None:
525
+ reason = (
526
+ "no health dimension could be measured yet — "
527
+ + "; ".join(
528
+ f"{name}: {dimensions[name].get('reason') or 'unavailable'}"
529
+ for name in unmeasured
530
+ )
531
+ )
464
532
 
465
533
  actions: List[Dict[str, str]] = []
466
534
  emb = dimensions["embedding_coverage"]
@@ -490,14 +558,18 @@ class BrainIntelligenceService:
490
558
  "reason": f"{cons_dim['contradiction_edges']} contradiction edges recorded in the graph.",
491
559
  })
492
560
 
493
- return {
561
+ report: Dict[str, Any] = {
494
562
  "overall_score": overall,
495
563
  "grade": grade,
496
564
  "dimensions": dimensions,
565
+ "coverage": coverage,
497
566
  "recommended_actions": actions,
498
567
  "graph_available": sample["available"],
499
568
  "generated_at": _now(),
500
569
  }
570
+ if reason is not None:
571
+ report["reason"] = reason
572
+ return report
501
573
 
502
574
  # ── vector freshness (v9.8.0) ────────────────────────────────────────
503
575
 
@@ -511,7 +583,51 @@ class BrainIntelligenceService:
511
583
  raises. The vector index is store-global (not workspace-partitioned);
512
584
  scope arguments are accepted for router symmetry but do not narrow
513
585
  the report.
586
+
587
+ Since v11.2.0 a store that can split its backlog also gets a
588
+ ``breakdown`` key (see :meth:`_freshness_breakdown`). It is additive:
589
+ the four keys above keep their meaning and their types, so the
590
+ freshness chip that reads ``pending_items`` is untouched, and a store
591
+ without the split simply has no ``breakdown``.
514
592
  """
593
+ payload = self._vector_freshness_contract()
594
+ breakdown = self._freshness_breakdown()
595
+ if breakdown is not None:
596
+ payload["breakdown"] = breakdown
597
+ return payload
598
+
599
+ def _freshness_breakdown(self) -> Optional[Dict[str, Any]]:
600
+ """``vector_freshness_breakdown()`` from the store, or ``None``.
601
+
602
+ "12 pending" hides two different situations — twelve items never
603
+ embedded (a new import) and twelve whose text changed under an
604
+ existing embedding (edits, where current answers are quietly wrong).
605
+ The store has always known the difference; until now nothing asked it.
606
+
607
+ ``None`` covers every reason the split is not available (graph off,
608
+ an older store, an unreadable index), because a caller can only act on
609
+ numbers that were really measured — never on zeros standing in for
610
+ them.
611
+ """
612
+ if not self._enable_graph or self._kg is None:
613
+ return None
614
+ breakdown_fn = getattr(self._kg, "vector_freshness_breakdown", None)
615
+ if not callable(breakdown_fn):
616
+ return None
617
+ try:
618
+ raw = breakdown_fn()
619
+ except Exception:
620
+ LOGGER.exception("vector freshness breakdown read failed")
621
+ return None
622
+ # An empty or unrecognisable answer is "not measured", which is what
623
+ # ``None`` already means here — publishing an empty block would claim
624
+ # a split nobody computed.
625
+ if not isinstance(raw, dict) or not raw:
626
+ return None
627
+ return dict(raw)
628
+
629
+ def _vector_freshness_contract(self) -> Dict[str, Any]:
630
+ """The four-key freshness payload, unchanged since v9.8.0."""
515
631
 
516
632
  def _unavailable(detail: str) -> Dict[str, Any]:
517
633
  return {
@@ -31,6 +31,11 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
31
31
 
32
32
  from latticeai.core.quiet import quiet
33
33
  from latticeai.core.tool_governor import classify_tool_call
34
+ from latticeai.core.workspace_reorganization import (
35
+ REORG_KIND,
36
+ apply_reorganization,
37
+ propose_reorganization,
38
+ )
34
39
 
35
40
  LOGGER = logging.getLogger(__name__)
36
41
 
@@ -301,6 +306,33 @@ class ChangeProposalService:
301
306
  )
302
307
  return item
303
308
 
309
+ def propose_reorganization(
310
+ self,
311
+ *,
312
+ root: str = "",
313
+ graph: Any = None,
314
+ user_email: Optional[str] = None,
315
+ workspace_id: Optional[str] = None,
316
+ max_moves: int = 20,
317
+ ) -> Dict[str, Any]:
318
+ """Stage a whole-folder reorganization as one reviewable proposal.
319
+
320
+ Moves only — see :mod:`latticeai.core.workspace_reorganization`. It
321
+ goes through this service (rather than the review queue directly) so
322
+ approving it from the Review Center applies it the same way every
323
+ other staged change is applied.
324
+ """
325
+ return propose_reorganization(
326
+ root=root,
327
+ resolve_path=self._resolve_path,
328
+ review_queue=self._review_queue,
329
+ graph=graph,
330
+ user_email=user_email,
331
+ workspace_id=workspace_id,
332
+ max_moves=max_moves,
333
+ audit=self._audit,
334
+ )
335
+
304
336
  # ── listing / apply / reject ─────────────────────────────────────────
305
337
 
306
338
  def pending(
@@ -362,8 +394,8 @@ class ChangeProposalService:
362
394
  raise KeyError(f"not a change proposal: {item_id}")
363
395
  payload = item.get("payload") or {}
364
396
  kind = str(item.get("kind") or "")
365
- path = str(payload.get("path") or "")
366
- if kind not in ("file_update", "file_delete"):
397
+ path = str(payload.get("path") or payload.get("root") or "")
398
+ if kind not in ("file_update", "file_delete", REORG_KIND):
367
399
  raise ValueError(f"unknown change proposal kind: {kind}")
368
400
 
369
401
  # Duplicate/concurrent approval guard: a resolved proposal must
@@ -375,20 +407,28 @@ class ChangeProposalService:
375
407
  rebase_hint="이미 처리된 제안입니다. 다시 적용할 수 없습니다.",
376
408
  )
377
409
 
378
- self._check_base_unchanged(payload, path=path, kind=kind)
379
-
380
- target = self._resolve_path(path)
381
- if kind == "file_update":
382
- self._atomic_write(target, str(payload.get("new_content") or ""))
383
- else: # file_delete — existence re-verified inside the lock
384
- if target.is_file():
410
+ moves: Optional[Dict[str, Any]] = None
411
+ if kind == REORG_KIND:
412
+ # A reorganization has no single base file to hash: each move
413
+ # is re-checked at apply time and a drifted one is skipped,
414
+ # never forced (see workspace_reorganization.apply_*).
415
+ moves = apply_reorganization(payload, resolve_path=self._resolve_path)
416
+ else:
417
+ self._check_base_unchanged(payload, path=path, kind=kind)
418
+ target = self._resolve_path(path)
419
+ if kind == "file_update":
420
+ self._atomic_write(target, str(payload.get("new_content") or ""))
421
+ elif target.is_file(): # file_delete — re-verified inside the lock
385
422
  target.unlink()
386
423
  approved = self._review_queue.approve(item_id, workspace_id=workspace_id)
387
424
  self._audit(
388
425
  "change_proposal_applied", user_email=user_email,
389
426
  proposal_id=item_id, path=path, kind=kind,
390
427
  )
391
- return {"item": approved, "applied": True, "path": path, "kind": kind}
428
+ result = {"item": approved, "applied": True, "path": path, "kind": kind}
429
+ if moves is not None:
430
+ result["moves"] = moves
431
+ return result
392
432
 
393
433
  def _check_base_unchanged(
394
434
  self, payload: Dict[str, Any], *, path: str, kind: str
@@ -214,11 +214,17 @@ class CommandCenterService:
214
214
  except Exception:
215
215
  LOGGER.exception("command center health read failed")
216
216
  return {"available": False}
217
- overall = report.get("overall") or {}
217
+ # ``BrainIntelligenceService.health_report()`` publishes the score and
218
+ # the grade at the top level. This used to read them out of a nested
219
+ # ``overall`` block that no producer has ever written, so the section
220
+ # was permanently unavailable: Today's Briefing showed a dash with no
221
+ # reason beside it, and the "check-health" quick action below could
222
+ # never fire. Read the shape the real collaborator returns.
223
+ score = report.get("overall_score")
218
224
  return {
219
- "available": overall.get("score") is not None,
220
- "grade": overall.get("grade"),
221
- "score": overall.get("score"),
225
+ "available": score is not None,
226
+ "grade": report.get("grade"),
227
+ "score": score,
222
228
  "recommended_actions": list(report.get("recommended_actions") or [])[:3],
223
229
  }
224
230