ltcai 9.6.0 → 9.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.
Files changed (60) hide show
  1. package/README.md +52 -42
  2. package/auto_setup.py +11 -1
  3. package/docs/CHANGELOG.md +54 -0
  4. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  5. package/docs/DEVELOPMENT.md +1 -1
  6. package/docs/ONBOARDING.md +1 -1
  7. package/docs/PERFORMANCE.md +106 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/kg_schema.py +11 -1
  12. package/knowledge_graph.py +12 -2
  13. package/knowledge_graph_api.py +11 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/proactive.py +583 -0
  16. package/lattice_brain/graph/retrieval.py +211 -7
  17. package/lattice_brain/graph/retrieval_vector.py +102 -0
  18. package/lattice_brain/ingestion.py +360 -4
  19. package/lattice_brain/quality.py +44 -9
  20. package/lattice_brain/runtime/multi_agent.py +38 -2
  21. package/latticeai/__init__.py +1 -1
  22. package/latticeai/api/brain_intelligence.py +27 -2
  23. package/latticeai/api/change_proposals.py +32 -3
  24. package/latticeai/api/review_queue.py +66 -2
  25. package/latticeai/app_factory.py +3 -0
  26. package/latticeai/core/agent.py +193 -135
  27. package/latticeai/core/agent_eval.py +155 -4
  28. package/latticeai/core/legacy_compatibility.py +15 -1
  29. package/latticeai/core/marketplace.py +1 -1
  30. package/latticeai/core/workspace_os.py +1 -1
  31. package/latticeai/runtime/router_registration.py +2 -0
  32. package/latticeai/services/architecture_readiness.py +1 -1
  33. package/latticeai/services/brain_intelligence.py +97 -1
  34. package/latticeai/services/change_proposals.py +56 -4
  35. package/latticeai/services/product_readiness.py +1 -1
  36. package/latticeai/services/review_queue.py +43 -2
  37. package/llm_router.py +10 -1
  38. package/local_knowledge_api.py +10 -1
  39. package/ltcai_cli.py +11 -1
  40. package/mcp_registry.py +10 -1
  41. package/p_reinforce.py +10 -1
  42. package/package.json +1 -1
  43. package/scripts/brain_quality_eval.py +1 -1
  44. package/scripts/check_current_release_docs.mjs +1 -1
  45. package/scripts/profile_kg.py +360 -0
  46. package/setup_wizard.py +10 -1
  47. package/src-tauri/Cargo.lock +1 -1
  48. package/src-tauri/Cargo.toml +1 -1
  49. package/src-tauri/tauri.conf.json +1 -1
  50. package/static/app/asset-manifest.json +10 -10
  51. package/static/app/assets/{Act-BkOEmwBi.js → Act-B6c39ays.js} +2 -1
  52. package/static/app/assets/{Brain-C9ITUsQ_.js → Brain-D7Qg4k6M.js} +1 -1
  53. package/static/app/assets/{Capture-C-ppTeud.js → Capture-VF_di68r.js} +1 -1
  54. package/static/app/assets/{Library-CGQbgWTu.js → Library-D_Gis2PA.js} +1 -1
  55. package/static/app/assets/{System-djmj0n2_.js → System-C5s5H2ov.js} +1 -1
  56. package/static/app/assets/{index-AF0-4XVv.js → index-DJC_2oub.js} +3 -3
  57. package/static/app/assets/{primitives-jbb2qv4Q.js → primitives-DL4Nip8C.js} +1 -1
  58. package/static/app/assets/{textarea-CFoo0OxJ.js → textarea-woZfCXHy.js} +1 -1
  59. package/static/app/index.html +1 -1
  60. package/static/sw.js +1 -1
@@ -34,6 +34,7 @@ from latticeai.core.agent import (
34
34
  AgentState,
35
35
  SingleAgentRuntime,
36
36
  )
37
+ from latticeai.tools import ToolError
37
38
 
38
39
  _AUTO_POLICY = {
39
40
  "auto_approve": True, "risk": "low", "shell": False, "network": False,
@@ -43,6 +44,43 @@ _DESTRUCTIVE_POLICY = {
43
44
  "auto_approve": False, "risk": "destructive", "shell": True, "network": False,
44
45
  "destructive": True, "sandbox": False, "rollback": "none",
45
46
  }
47
+ # Mirrors production write-tool governance when the change governor is wired:
48
+ # not auto-approved, so only the governor's additive/proposed verdicts (never
49
+ # the model) decide whether a write runs or is staged for review.
50
+ _GOVERNED_WRITE_POLICY = {
51
+ "auto_approve": False, "risk": "write", "shell": False, "network": False,
52
+ "destructive": False, "sandbox": "workspace", "rollback": "git",
53
+ }
54
+
55
+
56
+ class _EvalChangeGovernor:
57
+ """Deterministic stand-in for ChangeProposalService's governor port.
58
+
59
+ Mirrors the wire contract of
60
+ :meth:`latticeai.services.change_proposals.ChangeProposalService.review`:
61
+ ``None`` falls through, additive writes get ``allow_additive``, and
62
+ mutations of "existing" paths come back ``proposed`` with a proposal id —
63
+ exactly what the agent loop routes into the review queue.
64
+ """
65
+
66
+ governed_tools = frozenset({"write_file", "edit_file"})
67
+
68
+ def __init__(self) -> None:
69
+ self.proposals: List[Dict[str, Any]] = []
70
+
71
+ def review(self, name, args, *, policy=None, user_email=None, workspace_id=None, conversation_id=None):
72
+ if name not in self.governed_tools:
73
+ return None
74
+ path = str(args.get("path") or "")
75
+ if path.startswith("existing"):
76
+ proposal = {"id": f"eval-proposal-{len(self.proposals) + 1}", "path": path}
77
+ self.proposals.append(proposal)
78
+ return {
79
+ "decision": "proposed",
80
+ "classification": {"change_class": "mutation"},
81
+ "proposal": proposal,
82
+ }
83
+ return {"decision": "allow_additive", "classification": {"change_class": "additive"}}
46
84
 
47
85
 
48
86
  @dataclass
@@ -56,6 +94,11 @@ class Scenario:
56
94
  expect_min: Dict[str, int] = field(default_factory=dict)
57
95
  expect_exact: Dict[str, Any] = field(default_factory=dict)
58
96
  expect_tool_outcomes: Dict[str, int] = field(default_factory=dict)
97
+ # Exact ordered sequence of tool names that actually executed (successful
98
+ # execute_tool calls only — proposed/blocked/failed calls never run).
99
+ expect_tool_calls: List[str] = field(default_factory=list)
100
+ # Wire a change governor so governed tools follow the proposal path.
101
+ use_governor: bool = False
59
102
  max_steps: int = 8
60
103
 
61
104
 
@@ -69,7 +112,12 @@ class _Req:
69
112
  self.message = message
70
113
 
71
114
 
72
- def _build_deps(replies: List[str], tool_log: List[Dict[str, Any]]) -> AgentDeps:
115
+ def _build_deps(
116
+ replies: List[str],
117
+ tool_log: List[Dict[str, Any]],
118
+ *,
119
+ governor: Any = None,
120
+ ) -> AgentDeps:
73
121
  queue = list(replies)
74
122
 
75
123
  async def generate_as(model_id, message, context, max_tokens, temperature):
@@ -83,14 +131,21 @@ def _build_deps(replies: List[str], tool_log: List[Dict[str, Any]]) -> AgentDeps
83
131
  return '{"action": "noop"}'
84
132
 
85
133
  def execute_tool(name: str, args: dict) -> dict:
134
+ # File-producing tools fail like the real dispatcher when the model
135
+ # forgets the target path — scenarios use this to exercise recovery.
136
+ if name in ("write_file", "generate_file") and not args.get("path"):
137
+ raise ToolError(f"{name} requires args.path")
86
138
  tool_log.append({"name": name, "args": args})
87
139
  return {"ok": True, "path": args.get("path", "")}
88
140
 
89
141
  def policy_for(name: str, args: dict) -> dict:
90
142
  if name == "delete_everything":
91
143
  return dict(_DESTRUCTIVE_POLICY)
144
+ if governor is not None and name in ("write_file", "edit_file"):
145
+ return dict(_GOVERNED_WRITE_POLICY)
92
146
  return dict(_AUTO_POLICY)
93
147
 
148
+ write_policy = dict(_GOVERNED_WRITE_POLICY) if governor is not None else dict(_AUTO_POLICY)
94
149
  return AgentDeps(
95
150
  generate_as=generate_as,
96
151
  generate=generate,
@@ -99,17 +154,19 @@ def _build_deps(replies: List[str], tool_log: List[Dict[str, Any]]) -> AgentDeps
99
154
  risk_level=lambda p: p["risk"],
100
155
  check_role=lambda name, user: None,
101
156
  tool_governance={
102
- "write_file": dict(_AUTO_POLICY),
157
+ "write_file": write_policy,
103
158
  "read_file": dict(_AUTO_POLICY),
159
+ "generate_file": dict(_AUTO_POLICY),
104
160
  "delete_everything": dict(_DESTRUCTIVE_POLICY),
105
161
  },
106
- file_create_actions=frozenset({"write_file"}),
162
+ file_create_actions=frozenset({"write_file", "generate_file"}),
107
163
  recent_chat_context=lambda **kw: "",
108
164
  clear_history=lambda keep: {"ok": True},
109
165
  knowledge_save=lambda *a, **kw: None,
110
166
  audit=lambda *a, **kw: None,
111
167
  planner_prompt="plan", executor_prompt="exec", critic_prompt="critic",
112
168
  memory_updater_prompt="mem", agent_root=Path("/tmp/agent-eval"),
169
+ change_governor=governor,
113
170
  )
114
171
 
115
172
 
@@ -193,12 +250,96 @@ def default_scenarios() -> List[Scenario]:
193
250
  expect_min={"parse_errors": 3},
194
251
  expect_exact={"tool_outcomes": {}},
195
252
  ),
253
+ # ── file generation ─────────────────────────────────────────────
254
+ Scenario(
255
+ name="file-generation-happy-path",
256
+ replies=[
257
+ '{"action": "plan", "goal": "generate landing page", '
258
+ '"steps": [{"action": "generate_file"}]}',
259
+ # Small local models wrap the payload in reasoning + fences;
260
+ # the loop must still extract one clean tool call.
261
+ "<think>layout first, then hero copy</think>\n"
262
+ '```json\n{"thoughts": "produce the full document", '
263
+ '"action": "generate_file", "args": {"path": "site/index.html", '
264
+ '"content": "<!DOCTYPE html><html><body>hello</body></html>"}}\n```',
265
+ _FINAL,
266
+ _PASS,
267
+ ],
268
+ expect_exact={"parse_errors": 0},
269
+ expect_tool_outcomes={"ok": 1},
270
+ expect_tool_calls=["generate_file"],
271
+ ),
272
+ Scenario(
273
+ name="file-generation-bad-args-recovers",
274
+ replies=[
275
+ '{"action": "plan", "goal": "generate report", '
276
+ '"steps": [{"action": "generate_file"}]}',
277
+ # Malformed tool call: the model forgot the target path, the
278
+ # tool port fails like the real dispatcher would…
279
+ '{"thoughts": "write it", "action": "generate_file", '
280
+ '"args": {"content": "<html></html>"}}',
281
+ # …and the next turn recovers with a complete call.
282
+ '{"thoughts": "add the missing path", "action": "generate_file", '
283
+ '"args": {"path": "reports/q3.html", "content": "<html></html>"}}',
284
+ _FINAL,
285
+ _PASS,
286
+ ],
287
+ expect_tool_outcomes={"error": 1, "ok": 1},
288
+ expect_tool_calls=["generate_file"],
289
+ ),
290
+ # ── multi-step workflow ─────────────────────────────────────────
291
+ Scenario(
292
+ name="multi-step-workflow-chain",
293
+ replies=[
294
+ '{"action": "plan", "goal": "read spec, generate report, save summary", '
295
+ '"steps": [{"action": "read_file"}, {"action": "generate_file"}, '
296
+ '{"action": "write_file"}]}',
297
+ '{"thoughts": "step 1: read the source spec", '
298
+ '"action": "read_file", "args": {"path": "spec.md"}}',
299
+ '{"thoughts": "step 2: the spec asks for an HTML report", '
300
+ '"action": "generate_file", "args": {"path": "report.html", '
301
+ '"content": "<html><body>report</body></html>"}}',
302
+ '{"thoughts": "step 3: persist a short summary next to it", '
303
+ '"action": "write_file", "args": {"path": "summary.txt", '
304
+ '"content": "report generated"}}',
305
+ _FINAL,
306
+ _PASS,
307
+ ],
308
+ expect_exact={"parse_errors": 0},
309
+ expect_min={"llm_calls": 6},
310
+ expect_tool_outcomes={"ok": 3},
311
+ expect_tool_calls=["read_file", "generate_file", "write_file"],
312
+ ),
313
+ # ── governed-tool proposal path ─────────────────────────────────
314
+ Scenario(
315
+ name="governed-write-proposal-path",
316
+ use_governor=True,
317
+ replies=[
318
+ # write_file is NOT auto-approved here, but it is governed —
319
+ # approve() must not hard-block the plan (core invariant:
320
+ # governed tools are excluded from the non-auto set).
321
+ '{"action": "plan", "goal": "update existing page, add new note", '
322
+ '"steps": [{"action": "write_file"}]}',
323
+ # Mutation of existing content → staged as proposal, not written.
324
+ '{"thoughts": "rewrite the existing page", "action": "write_file", '
325
+ '"args": {"path": "existing/site.html", "content": "<new>"}}',
326
+ # Additive create → governor allows it to run immediately.
327
+ '{"thoughts": "add a fresh note", "action": "write_file", '
328
+ '"args": {"path": "fresh/new-note.md", "content": "hello"}}',
329
+ _FINAL,
330
+ _PASS,
331
+ ],
332
+ expect_exact={"parse_errors": 0},
333
+ expect_tool_outcomes={"proposed": 1, "ok": 1},
334
+ expect_tool_calls=["write_file"],
335
+ ),
196
336
  ]
197
337
 
198
338
 
199
339
  async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
200
340
  tool_log: List[Dict[str, Any]] = []
201
- deps = _build_deps(scenario.replies, tool_log)
341
+ governor = _EvalChangeGovernor() if scenario.use_governor else None
342
+ deps = _build_deps(scenario.replies, tool_log, governor=governor)
202
343
  runtime = SingleAgentRuntime(deps)
203
344
  ctx = AgentRunContext()
204
345
  ctx.state = AgentState.PLANNING
@@ -226,6 +367,14 @@ async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
226
367
  failures.append(
227
368
  f"tool_outcomes[{outcome}]={summary['tool_outcomes'].get(outcome, 0)} != {count}"
228
369
  )
370
+ executed = [call["name"] for call in tool_log]
371
+ if scenario.expect_tool_calls and executed != scenario.expect_tool_calls:
372
+ failures.append(f"tool_calls={executed} != {scenario.expect_tool_calls}")
373
+ if governor is not None and summary["tool_outcomes"].get("proposed", 0) != len(governor.proposals):
374
+ failures.append(
375
+ f"governor proposals={len(governor.proposals)} != "
376
+ f"traced proposed={summary['tool_outcomes'].get('proposed', 0)}"
377
+ )
229
378
  return {
230
379
  "name": scenario.name,
231
380
  "ok": not failures,
@@ -233,6 +382,8 @@ async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
233
382
  "final_state": ctx.state.value,
234
383
  "summary": summary,
235
384
  "tool_calls": len(tool_log),
385
+ "executed_tools": executed,
386
+ "proposals": len(governor.proposals) if governor is not None else 0,
236
387
  }
237
388
 
238
389
 
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "9.6.0"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.7.0"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -82,6 +82,20 @@ LEGACY_SHIMS: List[LegacyShim] = [
82
82
  reason="Old gardener scripts referenced the root reinforcement helper.",
83
83
  removal_phase="major-release-after-8.x",
84
84
  ),
85
+ LegacyShim(
86
+ path="mcp_registry.py",
87
+ owner="latticeai.core.mcp_registry",
88
+ replacement="import latticeai.core.mcp_registry",
89
+ reason="Older integrations imported the MCP registry from the repo root.",
90
+ removal_phase="major-release-after-9.x",
91
+ ),
92
+ LegacyShim(
93
+ path="llm_router.py",
94
+ owner="latticeai.models.router",
95
+ replacement="import latticeai.models.router",
96
+ reason="Historical scripts and tests imported the LLM router from the repo root.",
97
+ removal_phase="major-release-after-9.x",
98
+ ),
85
99
  LegacyShim(
86
100
  path="server.py",
87
101
  owner="latticeai.server_app",
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "9.6.0"
14
+ MARKETPLACE_VERSION = "9.7.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17
 
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "remove_skill_directory",
50
50
  ]
51
51
 
52
- WORKSPACE_OS_VERSION = "9.6.0"
52
+ WORKSPACE_OS_VERSION = "9.7.0"
53
53
 
54
54
  # Workspace types separate single-user Personal workspaces from shared
55
55
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -604,6 +604,7 @@ def register_review_and_brain_tail_routers(
604
604
  gardener: Any,
605
605
  create_setup_router: Any,
606
606
  model_router: Any,
607
+ change_proposals: Any = None,
607
608
  ) -> Any:
608
609
  """Register the final review/browser/brain tail routes in legacy order."""
609
610
 
@@ -616,6 +617,7 @@ def register_review_and_brain_tail_routers(
616
617
  gate_write=gate_write,
617
618
  run_review_item=run_review_item,
618
619
  append_audit_event=append_audit_event,
620
+ change_proposals=change_proposals,
619
621
  ),
620
622
  create_browser_router(
621
623
  pipeline=ingestion_pipeline,
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.6.0"
20
+ ARCHITECTURE_VERSION_TARGET = "9.7.0"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -65,6 +65,23 @@ class BrainIntelligenceService:
65
65
  self._enable_graph = bool(enable_graph and knowledge_graph is not None)
66
66
  self._memory_quality = MemoryQualityManager()
67
67
  self._edge_quality = GraphEdgeQualityManager()
68
+ self._proactive_brain: Any = None
69
+
70
+ def _proactive(self) -> Any:
71
+ """Lazy graph-layer ProactiveBrain over the injected store (or None)."""
72
+ if not self._enable_graph:
73
+ return None
74
+ if self._proactive_brain is None:
75
+ try:
76
+ from lattice_brain.graph.proactive import ProactiveBrain
77
+
78
+ self._proactive_brain = ProactiveBrain(
79
+ self._kg, sample_limit=_GRAPH_SAMPLE_LIMIT
80
+ )
81
+ except Exception:
82
+ LOGGER.exception("proactive brain initialization failed")
83
+ return None
84
+ return self._proactive_brain
68
85
 
69
86
  # ── shared graph sampling ─────────────────────────────────────────────
70
87
 
@@ -302,6 +319,56 @@ class BrainIntelligenceService:
302
319
  "generated_at": _now(),
303
320
  }
304
321
 
322
+ # ── graph-layer proactive quality (v9.6.x) ───────────────────────────
323
+
324
+ def graph_duplicates(
325
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
326
+ ) -> Dict[str, Any]:
327
+ """Duplicate graph nodes (exact groups + near pairs) — read only."""
328
+ proactive = self._proactive()
329
+ if proactive is None:
330
+ return {
331
+ "available": False,
332
+ "exact_groups": [],
333
+ "near_pairs": [],
334
+ "exact_duplicate_nodes": 0,
335
+ "nodes_scanned": 0,
336
+ "generated_at": _now(),
337
+ }
338
+ try:
339
+ result = dict(proactive.find_duplicates(workspace_id=workspace_id))
340
+ except Exception as exc:
341
+ LOGGER.exception("graph duplicates scan failed")
342
+ return {
343
+ "available": False,
344
+ "error": str(exc),
345
+ "exact_groups": [],
346
+ "near_pairs": [],
347
+ "exact_duplicate_nodes": 0,
348
+ "nodes_scanned": 0,
349
+ "generated_at": _now(),
350
+ }
351
+ result["available"] = True
352
+ result["generated_at"] = _now()
353
+ return result
354
+
355
+ def quality_report(
356
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
357
+ ) -> Dict[str, Any]:
358
+ """Combined graph quality report: duplicates, contradictions, stale
359
+ nodes, edge quality — one workspace-scoped graph sample."""
360
+ proactive = self._proactive()
361
+ if proactive is None:
362
+ return {"available": False, "generated_at": _now()}
363
+ try:
364
+ result = dict(proactive.quality_report(workspace_id=workspace_id))
365
+ except Exception as exc:
366
+ LOGGER.exception("graph quality report failed")
367
+ return {"available": False, "error": str(exc), "generated_at": _now()}
368
+ result["available"] = True
369
+ result["generated_at"] = _now()
370
+ return result
371
+
305
372
  # ── contradictions ───────────────────────────────────────────────────
306
373
 
307
374
  def contradictions(
@@ -365,7 +432,21 @@ class BrainIntelligenceService:
365
432
  "signal": "contradicts_edge",
366
433
  })
367
434
 
368
- items = conflicts + temporal_items + edge_items
435
+ # v9.6.x additive: graph-layer node-content contradictions (proactive
436
+ # detector over node title/summary), on top of the memory + edge scans.
437
+ graph_pair_items: List[Dict[str, Any]] = []
438
+ proactive = self._proactive()
439
+ if proactive is not None:
440
+ try:
441
+ graph_result = proactive.detect_contradictions(workspace_id=workspace_id)
442
+ graph_pair_items = [
443
+ {"kind": "graph_node_pair", **pair}
444
+ for pair in graph_result.get("node_pairs") or []
445
+ ]
446
+ except Exception:
447
+ LOGGER.exception("graph contradiction scan failed")
448
+
449
+ items = conflicts + temporal_items + edge_items + graph_pair_items
369
450
  return {
370
451
  "items": items,
371
452
  "count": len(items),
@@ -373,6 +454,7 @@ class BrainIntelligenceService:
373
454
  "memory_pairs": len(conflicts),
374
455
  "temporal": len(temporal_items),
375
456
  "graph_edges": len(edge_items),
457
+ "graph_node_pairs": len(graph_pair_items),
376
458
  },
377
459
  "memories_scanned": len(memory_rows),
378
460
  "generated_at": _now(),
@@ -422,6 +504,19 @@ class BrainIntelligenceService:
422
504
  except Exception:
423
505
  LOGGER.exception("consolidation prune failed")
424
506
 
507
+ # v9.6.x additive: graph-layer node merge plan. Always dry-run from
508
+ # this service — graph content changes stay proposal-first; the plan
509
+ # is surfaced so a governed apply path can adopt it later.
510
+ graph_consolidation: Optional[Dict[str, Any]] = None
511
+ proactive = self._proactive()
512
+ if proactive is not None:
513
+ try:
514
+ graph_consolidation = proactive.consolidate_duplicates(
515
+ workspace_id=workspace_id, dry_run=True
516
+ )
517
+ except Exception:
518
+ LOGGER.exception("graph consolidation plan failed")
519
+
425
520
  return {
426
521
  "mode": "applied" if apply else "dry_run",
427
522
  "memories_scanned": len(memory_rows),
@@ -432,6 +527,7 @@ class BrainIntelligenceService:
432
527
  # mutates graph content directly.
433
528
  "duplicate_edges": duplicate_edge_ids[:50],
434
529
  "duplicate_edge_count": len(duplicate_edge_ids),
530
+ "graph_consolidation": graph_consolidation,
435
531
  "generated_at": _now(),
436
532
  }
437
533
 
@@ -71,6 +71,7 @@ class ChangeProposalService:
71
71
  policy: Optional[Dict[str, Any]] = None,
72
72
  user_email: Optional[str] = None,
73
73
  workspace_id: Optional[str] = None,
74
+ conversation_id: Optional[str] = None,
74
75
  ) -> Optional[Dict[str, Any]]:
75
76
  """Governor port for the agent loop.
76
77
 
@@ -102,6 +103,15 @@ class ChangeProposalService:
102
103
  reason=verdict["reason"],
103
104
  user_email=user_email,
104
105
  workspace_id=workspace_id,
106
+ context={
107
+ # Full provenance for the Review Center: which tool asked,
108
+ # how the governor classified it, and the policy risk.
109
+ "tool": name,
110
+ "change_class": verdict.get("change_class"),
111
+ "risk": (policy or {}).get("risk"),
112
+ "conversation_id": conversation_id,
113
+ "source_detail": "agent change governor",
114
+ },
105
115
  )
106
116
  except Exception:
107
117
  LOGGER.exception("change proposal staging failed")
@@ -152,10 +162,15 @@ class ChangeProposalService:
152
162
  reason: str = "",
153
163
  user_email: Optional[str] = None,
154
164
  workspace_id: Optional[str] = None,
165
+ context: Optional[Dict[str, Any]] = None,
155
166
  ) -> Dict[str, Any]:
156
167
  before = self._read_before(path)
157
168
  diff = _unified_diff(before, new_content, path)
158
169
  tier = "small" if len(diff) <= _SMALL_TIER_DIFF_LINES else "large"
170
+ provenance: Dict[str, Any] = {"proposed_by": proposed_by, "reason": reason}
171
+ for key, value in (context or {}).items():
172
+ if value is not None and key not in provenance:
173
+ provenance[key] = value
159
174
  item = self._review_queue.create(
160
175
  title=f"파일 수정 제안: {path}",
161
176
  summary=reason or "기존 파일을 변경하는 작업이라 검토 후 적용됩니다.",
@@ -169,7 +184,7 @@ class ChangeProposalService:
169
184
  "before_bytes": len(before.encode("utf-8")),
170
185
  "after_bytes": len(new_content.encode("utf-8")),
171
186
  },
172
- provenance={"proposed_by": proposed_by, "reason": reason},
187
+ provenance=provenance,
173
188
  user_email=user_email,
174
189
  workspace_id=workspace_id,
175
190
  )
@@ -232,11 +247,36 @@ class ChangeProposalService:
232
247
  },
233
248
  }
234
249
 
250
+ def counts(
251
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
252
+ ) -> Dict[str, Any]:
253
+ """Pending proposal count for the Review Center badge."""
254
+ listed = self._review_queue.list(
255
+ workspace_id=workspace_id, user_email=user_email,
256
+ status="pending", source="change_proposal",
257
+ )
258
+ return {"pending": len(listed.get("items") or [])}
259
+
260
+ def get_proposal(
261
+ self, item_id: str, *, workspace_id: Optional[str] = None
262
+ ) -> Dict[str, Any]:
263
+ """Full proposal detail (diff + staged content) for the preview UI.
264
+
265
+ Raises ``KeyError`` when the id exists but is not a change proposal,
266
+ so the API surface cannot leak arbitrary review items.
267
+ """
268
+ item = self._review_queue.get(item_id, workspace_id=workspace_id)
269
+ if item.get("source") != "change_proposal":
270
+ raise KeyError(f"not a change proposal: {item_id}")
271
+ return item
272
+
235
273
  def approve_and_apply(
236
274
  self, item_id: str, *, user_email: Optional[str] = None,
237
275
  workspace_id: Optional[str] = None,
238
276
  ) -> Dict[str, Any]:
239
277
  item = self._review_queue.get(item_id, workspace_id=workspace_id)
278
+ if item.get("source") != "change_proposal":
279
+ raise KeyError(f"not a change proposal: {item_id}")
240
280
  payload = item.get("payload") or {}
241
281
  kind = item.get("kind")
242
282
  path = str(payload.get("path") or "")
@@ -258,13 +298,25 @@ class ChangeProposalService:
258
298
 
259
299
  def reject(
260
300
  self, item_id: str, *, user_email: Optional[str] = None,
261
- workspace_id: Optional[str] = None,
301
+ workspace_id: Optional[str] = None, reason: str = "",
262
302
  ) -> Dict[str, Any]:
263
- dismissed = self._review_queue.dismiss(item_id, workspace_id=workspace_id)
303
+ self.get_proposal(item_id, workspace_id=workspace_id) # source guard
304
+ reason = str(reason or "").strip()[:500]
305
+ if reason:
306
+ try:
307
+ dismissed = self._review_queue.dismiss(
308
+ item_id, workspace_id=workspace_id, reason=reason
309
+ )
310
+ except TypeError:
311
+ # Older/fake queues without reason support still dismiss.
312
+ dismissed = self._review_queue.dismiss(item_id, workspace_id=workspace_id)
313
+ else:
314
+ dismissed = self._review_queue.dismiss(item_id, workspace_id=workspace_id)
264
315
  self._audit(
265
316
  "change_proposal_rejected", user_email=user_email, proposal_id=item_id,
317
+ reason=reason or None,
266
318
  )
267
- return {"item": dismissed, "applied": False}
319
+ return {"item": dismissed, "applied": False, "reason": reason}
268
320
 
269
321
 
270
322
  __all__ = ["ChangeProposalService"]
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "9.6.0"
21
+ PRODUCT_VERSION_TARGET = "9.7.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -141,8 +141,49 @@ class ReviewQueueService:
141
141
  updated = self._store.update_review_item(item_id, workspace_id=workspace_id, **patch)
142
142
  return self._view(updated)
143
143
 
144
- def dismiss(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
145
- return self._transition(item_id, "dismiss", "dismissed", workspace_id=workspace_id)
144
+ def dismiss(
145
+ self, item_id: str, *, workspace_id: Optional[str] = None,
146
+ reason: Optional[str] = None,
147
+ ) -> Dict[str, Any]:
148
+ """Dismiss an item; an optional ``reason`` is kept in provenance.
149
+
150
+ The reason matters most for ``change_proposal`` items — the review
151
+ timeline doubles as the change audit log, so "why was this rejected"
152
+ should survive next to the staged diff.
153
+ """
154
+ if not reason:
155
+ return self._transition(item_id, "dismiss", "dismissed", workspace_id=workspace_id)
156
+ item = self._store.get_review_item(item_id, workspace_id=workspace_id)
157
+ self._guard("dismiss", item)
158
+ provenance = dict(item.get("provenance") or {})
159
+ provenance["dismiss_reason"] = str(reason)[:500]
160
+ patch: Dict[str, Any] = {"status": "dismissed", "provenance": provenance}
161
+ if item.get("snoozed_until") is not None:
162
+ patch["snoozed_until"] = None
163
+ updated = self._store.update_review_item(item_id, workspace_id=workspace_id, **patch)
164
+ return self._view(updated)
165
+
166
+ def counts(
167
+ self, *, workspace_id: Optional[str] = None, user_email: Optional[str] = None,
168
+ ) -> Dict[str, Any]:
169
+ """Badge-friendly effective-status counts (pending includes expired snoozes)."""
170
+ items = [
171
+ self._view(it)
172
+ for it in self._store.list_review_items(
173
+ workspace_id=workspace_id, user_email=user_email,
174
+ )
175
+ ]
176
+ pending = [it for it in items if it["effective_status"] == "pending"]
177
+ snoozed = [it for it in items if it["effective_status"] == "snoozed"]
178
+ by_source: Dict[str, int] = {}
179
+ for it in pending:
180
+ source = str(it.get("source") or "workflow_run")
181
+ by_source[source] = by_source.get(source, 0) + 1
182
+ return {
183
+ "pending": len(pending),
184
+ "snoozed": len(snoozed),
185
+ "pending_by_source": by_source,
186
+ }
146
187
 
147
188
  def snooze(
148
189
  self, item_id: str, *, until: str, workspace_id: Optional[str] = None,
package/llm_router.py CHANGED
@@ -5,7 +5,16 @@ monkeypatching keep working through the old import path.
5
5
  """
6
6
 
7
7
  import sys
8
+ import warnings
8
9
 
9
- import latticeai.models.router as _impl
10
+ warnings.warn(
11
+ "Importing 'llm_router' from the repository root is deprecated; "
12
+ "use 'import latticeai.models.router' instead. "
13
+ "The root shim will be removed in a future major release.",
14
+ DeprecationWarning,
15
+ stacklevel=2,
16
+ )
17
+
18
+ import latticeai.models.router as _impl # noqa: E402
10
19
 
11
20
  sys.modules[__name__] = _impl
@@ -1,7 +1,16 @@
1
1
  """Compatibility shim for :mod:`latticeai.services.local_knowledge`."""
2
2
 
3
3
  import sys
4
+ import warnings
4
5
 
5
- from latticeai.services import local_knowledge as _impl
6
+ warnings.warn(
7
+ "Importing 'local_knowledge_api' from the repository root is deprecated; "
8
+ "use 'from latticeai.services import local_knowledge' instead. "
9
+ "The root shim will be removed in a future major release.",
10
+ DeprecationWarning,
11
+ stacklevel=2,
12
+ )
13
+
14
+ from latticeai.services import local_knowledge as _impl # noqa: E402
6
15
 
7
16
  sys.modules[__name__] = _impl