ltcai 9.5.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 (71) hide show
  1. package/README.md +67 -43
  2. package/auto_setup.py +11 -1
  3. package/docs/CHANGELOG.md +86 -0
  4. package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
  5. package/docs/DEVELOPMENT.md +8 -8
  6. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  7. package/docs/ONBOARDING.md +2 -2
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/PERFORMANCE.md +106 -0
  10. package/docs/TRUST_MODEL.md +1 -1
  11. package/docs/WHY_LATTICE.md +1 -1
  12. package/docs/kg-schema.md +1 -1
  13. package/docs/mcp-tools.md +1 -1
  14. package/kg_schema.py +11 -1
  15. package/knowledge_graph.py +12 -2
  16. package/knowledge_graph_api.py +11 -1
  17. package/lattice_brain/__init__.py +1 -1
  18. package/lattice_brain/graph/proactive.py +583 -0
  19. package/lattice_brain/graph/retrieval.py +211 -7
  20. package/lattice_brain/graph/retrieval_vector.py +102 -0
  21. package/lattice_brain/ingestion.py +360 -4
  22. package/lattice_brain/quality.py +44 -9
  23. package/lattice_brain/runtime/multi_agent.py +38 -2
  24. package/latticeai/__init__.py +1 -1
  25. package/latticeai/api/brain_intelligence.py +27 -2
  26. package/latticeai/api/change_proposals.py +89 -0
  27. package/latticeai/api/chat_agent_http.py +2 -0
  28. package/latticeai/api/review_queue.py +66 -2
  29. package/latticeai/app_factory.py +23 -0
  30. package/latticeai/cli/entrypoint.py +3 -1
  31. package/latticeai/core/agent.py +273 -101
  32. package/latticeai/core/agent_eval.py +411 -0
  33. package/latticeai/core/agent_trace.py +104 -0
  34. package/latticeai/core/legacy_compatibility.py +15 -1
  35. package/latticeai/core/marketplace.py +1 -1
  36. package/latticeai/core/tool_governor.py +101 -0
  37. package/latticeai/core/workspace_os.py +1 -1
  38. package/latticeai/runtime/router_registration.py +2 -0
  39. package/latticeai/services/architecture_readiness.py +1 -1
  40. package/latticeai/services/brain_intelligence.py +97 -1
  41. package/latticeai/services/change_proposals.py +322 -0
  42. package/latticeai/services/product_readiness.py +1 -1
  43. package/latticeai/services/review_queue.py +47 -3
  44. package/latticeai/tools/__init__.py +6 -1
  45. package/llm_router.py +10 -1
  46. package/local_knowledge_api.py +10 -1
  47. package/ltcai_cli.py +11 -1
  48. package/mcp_registry.py +10 -1
  49. package/p_reinforce.py +10 -1
  50. package/package.json +1 -1
  51. package/scripts/agent_eval.py +46 -0
  52. package/scripts/brain_quality_eval.py +1 -1
  53. package/scripts/check_current_release_docs.mjs +2 -1
  54. package/scripts/profile_kg.py +360 -0
  55. package/setup_wizard.py +10 -1
  56. package/src-tauri/Cargo.lock +1 -1
  57. package/src-tauri/Cargo.toml +1 -1
  58. package/src-tauri/tauri.conf.json +1 -1
  59. package/static/app/asset-manifest.json +11 -11
  60. package/static/app/assets/{Act-Cdfqx4wN.js → Act-B6c39ays.js} +2 -1
  61. package/static/app/assets/{Brain-w_tAuyg6.js → Brain-D7Qg4k6M.js} +1 -1
  62. package/static/app/assets/{Capture-iJwi9LmS.js → Capture-VF_di68r.js} +1 -1
  63. package/static/app/assets/{Library-Do-jjhzi.js → Library-D_Gis2PA.js} +1 -1
  64. package/static/app/assets/{System-DFg_q3E6.js → System-C5s5H2ov.js} +1 -1
  65. package/static/app/assets/{index-BN6HIWVC.css → index-85wQvEie.css} +1 -1
  66. package/static/app/assets/index-DJC_2oub.js +18 -0
  67. package/static/app/assets/{primitives-ZTUlU7pR.js → primitives-DL4Nip8C.js} +1 -1
  68. package/static/app/assets/{textarea-CMfhqPhE.js → textarea-woZfCXHy.js} +1 -1
  69. package/static/app/index.html +2 -2
  70. package/static/sw.js +1 -1
  71. package/static/app/assets/index-aJuRi-Xo.js +0 -17
@@ -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.5.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
 
@@ -0,0 +1,322 @@
1
+ """Change proposals — 수정/삭제는 제안으로, 생성은 바로 (v9.6.0).
2
+
3
+ The consent model most users actually want is git's: creating something new
4
+ is cheap and reversible, but changing or deleting what already exists
5
+ deserves a review. This service implements that model for the agent
6
+ workspace:
7
+
8
+ * **additive** operations (new files, new notes) run with minimal friction;
9
+ * **mutation/destructive** operations (overwrite, in-place edit, delete of
10
+ an existing file) are *staged as proposals* instead of applied: a review
11
+ item (source ``change_proposal``) carrying the target path, a unified
12
+ diff, the exact resulting content, and a small/large tier. Nothing changes
13
+ on disk until the user approves; rejecting discards the staged change.
14
+
15
+ Approving applies the staged content exactly as reviewed (never recomputed),
16
+ then marks the review item approved — so the review timeline doubles as the
17
+ change audit log. The classification itself lives in
18
+ :mod:`latticeai.core.tool_governor` so every entry point shares one policy.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import difflib
24
+ import logging
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Dict, List, Optional
27
+
28
+ from latticeai.core.tool_governor import classify_tool_call
29
+
30
+ LOGGER = logging.getLogger(__name__)
31
+
32
+ _MAX_STAGED_BYTES = 400_000
33
+ _MAX_DIFF_LINES = 400
34
+ _SMALL_TIER_DIFF_LINES = 40
35
+
36
+
37
+ def _unified_diff(before: str, after: str, path: str) -> List[str]:
38
+ lines = list(
39
+ difflib.unified_diff(
40
+ before.splitlines(), after.splitlines(),
41
+ fromfile=f"a/{path}", tofile=f"b/{path}", lineterm="",
42
+ )
43
+ )
44
+ return lines[:_MAX_DIFF_LINES]
45
+
46
+
47
+ class ChangeProposalService:
48
+ """Stages mutation/destructive file changes as reviewable proposals."""
49
+
50
+ #: Tools whose plan-time approval is delegated to per-call governance.
51
+ governed_tools = frozenset({"write_file", "edit_file"})
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ review_queue: Any,
57
+ resolve_path: Callable[[str], Path],
58
+ audit: Optional[Callable[..., None]] = None,
59
+ ) -> None:
60
+ self._review_queue = review_queue
61
+ self._resolve_path = resolve_path
62
+ self._audit = audit or (lambda *a, **kw: None)
63
+
64
+ # ── classification / staging (agent-loop governor port) ─────────────
65
+
66
+ def review(
67
+ self,
68
+ name: str,
69
+ args: Dict[str, Any],
70
+ *,
71
+ policy: Optional[Dict[str, Any]] = None,
72
+ user_email: Optional[str] = None,
73
+ workspace_id: Optional[str] = None,
74
+ conversation_id: Optional[str] = None,
75
+ ) -> Optional[Dict[str, Any]]:
76
+ """Governor port for the agent loop.
77
+
78
+ Returns ``None`` to fall through to the existing gates, or a verdict:
79
+ ``{"decision": "allow_additive"}`` (execute without extra approval) /
80
+ ``{"decision": "proposed", "proposal": {...}}`` (staged for review).
81
+ """
82
+ if name not in {"write_file", "edit_file"}:
83
+ return None
84
+ verdict = classify_tool_call(
85
+ name, args, policy=policy, path_exists=self._path_exists
86
+ )
87
+ if verdict["change_class"] == "additive":
88
+ return {"decision": "allow_additive", "classification": verdict}
89
+ if not verdict["proposal_required"]:
90
+ return None
91
+
92
+ path = str(args.get("path") or "")
93
+ after = self._staged_content(name, args)
94
+ if after is None:
95
+ # The edit cannot be computed deterministically (e.g. old_string
96
+ # not found) — let the normal tool path surface the real error.
97
+ return None
98
+ try:
99
+ proposal = self.propose_file_update(
100
+ path=path,
101
+ new_content=after,
102
+ proposed_by="agent",
103
+ reason=verdict["reason"],
104
+ user_email=user_email,
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
+ },
115
+ )
116
+ except Exception:
117
+ LOGGER.exception("change proposal staging failed")
118
+ return None
119
+ return {"decision": "proposed", "classification": verdict, "proposal": proposal}
120
+
121
+ def _path_exists(self, path: str) -> bool:
122
+ try:
123
+ return self._resolve_path(path).is_file()
124
+ except Exception:
125
+ return False
126
+
127
+ def _read_before(self, path: str) -> str:
128
+ try:
129
+ target = self._resolve_path(path)
130
+ if not target.is_file():
131
+ return ""
132
+ raw = target.read_bytes()[:_MAX_STAGED_BYTES]
133
+ return raw.decode("utf-8", errors="replace")
134
+ except Exception:
135
+ LOGGER.exception("change proposal read failed")
136
+ return ""
137
+
138
+ def _staged_content(self, name: str, args: Dict[str, Any]) -> Optional[str]:
139
+ if name == "write_file":
140
+ return str(args.get("content") or "")[:_MAX_STAGED_BYTES]
141
+ if name == "edit_file":
142
+ before = self._read_before(str(args.get("path") or ""))
143
+ old = str(args.get("old_string") or "")
144
+ new = str(args.get("new_string") or "")
145
+ if not old or old not in before:
146
+ return None
147
+ if args.get("replace_all"):
148
+ return before.replace(old, new)[:_MAX_STAGED_BYTES]
149
+ if before.count(old) != 1:
150
+ return None
151
+ return before.replace(old, new, 1)[:_MAX_STAGED_BYTES]
152
+ return None
153
+
154
+ # ── proposal creation ────────────────────────────────────────────────
155
+
156
+ def propose_file_update(
157
+ self,
158
+ *,
159
+ path: str,
160
+ new_content: str,
161
+ proposed_by: str = "agent",
162
+ reason: str = "",
163
+ user_email: Optional[str] = None,
164
+ workspace_id: Optional[str] = None,
165
+ context: Optional[Dict[str, Any]] = None,
166
+ ) -> Dict[str, Any]:
167
+ before = self._read_before(path)
168
+ diff = _unified_diff(before, new_content, path)
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
174
+ item = self._review_queue.create(
175
+ title=f"파일 수정 제안: {path}",
176
+ summary=reason or "기존 파일을 변경하는 작업이라 검토 후 적용됩니다.",
177
+ source="change_proposal",
178
+ kind="file_update",
179
+ payload={
180
+ "path": path,
181
+ "diff": diff,
182
+ "new_content": new_content[:_MAX_STAGED_BYTES],
183
+ "tier": tier,
184
+ "before_bytes": len(before.encode("utf-8")),
185
+ "after_bytes": len(new_content.encode("utf-8")),
186
+ },
187
+ provenance=provenance,
188
+ user_email=user_email,
189
+ workspace_id=workspace_id,
190
+ )
191
+ self._audit(
192
+ "change_proposal_created", user_email=user_email,
193
+ proposal_id=item.get("id"), path=path, kind="file_update", tier=tier,
194
+ )
195
+ return item
196
+
197
+ def propose_file_delete(
198
+ self,
199
+ *,
200
+ path: str,
201
+ proposed_by: str = "agent",
202
+ reason: str = "",
203
+ user_email: Optional[str] = None,
204
+ workspace_id: Optional[str] = None,
205
+ ) -> Dict[str, Any]:
206
+ before = self._read_before(path)
207
+ item = self._review_queue.create(
208
+ title=f"파일 삭제 제안: {path}",
209
+ summary=reason or "기존 파일을 삭제하는 작업이라 검토 후 적용됩니다.",
210
+ source="change_proposal",
211
+ kind="file_delete",
212
+ payload={
213
+ "path": path,
214
+ "diff": _unified_diff(before, "", path),
215
+ "tier": "large",
216
+ "before_bytes": len(before.encode("utf-8")),
217
+ "after_bytes": 0,
218
+ },
219
+ provenance={"proposed_by": proposed_by, "reason": reason},
220
+ user_email=user_email,
221
+ workspace_id=workspace_id,
222
+ )
223
+ self._audit(
224
+ "change_proposal_created", user_email=user_email,
225
+ proposal_id=item.get("id"), path=path, kind="file_delete", tier="large",
226
+ )
227
+ return item
228
+
229
+ # ── listing / apply / reject ─────────────────────────────────────────
230
+
231
+ def pending(
232
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
233
+ ) -> Dict[str, Any]:
234
+ listed = self._review_queue.list(
235
+ workspace_id=workspace_id, user_email=user_email,
236
+ status="pending", source="change_proposal",
237
+ )
238
+ items = listed.get("items") or []
239
+ return {
240
+ "items": items,
241
+ "count": len(items),
242
+ "contract": {
243
+ "additive_writes": "auto",
244
+ "mutations": "proposal",
245
+ "deletions": "proposal",
246
+ "applied_content": "exactly_as_reviewed",
247
+ },
248
+ }
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
+
273
+ def approve_and_apply(
274
+ self, item_id: str, *, user_email: Optional[str] = None,
275
+ workspace_id: Optional[str] = None,
276
+ ) -> Dict[str, Any]:
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}")
280
+ payload = item.get("payload") or {}
281
+ kind = item.get("kind")
282
+ path = str(payload.get("path") or "")
283
+ target = self._resolve_path(path)
284
+ if kind == "file_update":
285
+ target.parent.mkdir(parents=True, exist_ok=True)
286
+ target.write_text(str(payload.get("new_content") or ""), encoding="utf-8")
287
+ elif kind == "file_delete":
288
+ if target.is_file():
289
+ target.unlink()
290
+ else:
291
+ raise ValueError(f"unknown change proposal kind: {kind}")
292
+ approved = self._review_queue.approve(item_id, workspace_id=workspace_id)
293
+ self._audit(
294
+ "change_proposal_applied", user_email=user_email,
295
+ proposal_id=item_id, path=path, kind=kind,
296
+ )
297
+ return {"item": approved, "applied": True, "path": path, "kind": kind}
298
+
299
+ def reject(
300
+ self, item_id: str, *, user_email: Optional[str] = None,
301
+ workspace_id: Optional[str] = None, reason: str = "",
302
+ ) -> Dict[str, Any]:
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)
315
+ self._audit(
316
+ "change_proposal_rejected", user_email=user_email, proposal_id=item_id,
317
+ reason=reason or None,
318
+ )
319
+ return {"item": dismissed, "applied": False, "reason": reason}
320
+
321
+
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.5.0"
21
+ PRODUCT_VERSION_TARGET = "9.7.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -28,7 +28,10 @@ OPEN_STATUSES = {"pending", "snoozed"}
28
28
  TERMINAL_STATUSES = {"approved", "dismissed"}
29
29
  ALL_STATUSES = OPEN_STATUSES | TERMINAL_STATUSES
30
30
 
31
- REVIEW_SOURCES = frozenset({"workflow_run", "trigger", "kg_change_digest", "chat_followup", "agent_followup"})
31
+ REVIEW_SOURCES = frozenset({
32
+ "workflow_run", "trigger", "kg_change_digest", "chat_followup",
33
+ "agent_followup", "change_proposal",
34
+ })
32
35
 
33
36
  # Which source statuses each action is allowed from.
34
37
  _ALLOWED_FROM: Dict[str, set] = {
@@ -138,8 +141,49 @@ class ReviewQueueService:
138
141
  updated = self._store.update_review_item(item_id, workspace_id=workspace_id, **patch)
139
142
  return self._view(updated)
140
143
 
141
- def dismiss(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
142
- 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
+ }
143
187
 
144
188
  def snooze(
145
189
  self, item_id: str, *, until: str, workspace_id: Optional[str] = None,
@@ -124,6 +124,11 @@ def _resolve_path(path: str = "") -> Path:
124
124
  return candidate
125
125
 
126
126
 
127
+ def resolve_workspace_path(path: str = "") -> Path:
128
+ """Public alias of the sandboxed workspace path resolver (v9.6.0)."""
129
+ return _resolve_path(path)
130
+
131
+
127
132
  def _relative(path: Path) -> str:
128
133
  return str(path.relative_to(AGENT_ROOT))
129
134
 
@@ -259,7 +264,7 @@ def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
259
264
 
260
265
 
261
266
  __all__ = [
262
- "AGENT_ROOT", "ToolError", "ensure_agent_root",
267
+ "AGENT_ROOT", "ToolError", "ensure_agent_root", "resolve_workspace_path",
263
268
  "list_dir", "workspace_tree", "read_file", "write_file", "edit_file", "grep",
264
269
  "search_files", "inspect_html", "preview_url", "create_web_project",
265
270
  "todo_read", "todo_write",
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
package/ltcai_cli.py CHANGED
@@ -1,6 +1,16 @@
1
1
  """Compatibility shim for the historical root CLI module."""
2
2
 
3
- from latticeai.cli import entrypoint as _impl
3
+ import warnings
4
+
5
+ warnings.warn(
6
+ "Importing 'ltcai_cli' from the repository root is deprecated; "
7
+ "use 'from latticeai.cli import entrypoint' instead. "
8
+ "The root shim will be removed in a future major release.",
9
+ DeprecationWarning,
10
+ stacklevel=2,
11
+ )
12
+
13
+ from latticeai.cli import entrypoint as _impl # noqa: E402
4
14
 
5
15
 
6
16
  if __name__ == "__main__":
package/mcp_registry.py CHANGED
@@ -5,7 +5,16 @@ and monkeypatching keep working through the old import path.
5
5
  """
6
6
 
7
7
  import sys
8
+ import warnings
8
9
 
9
- import latticeai.core.mcp_registry as _impl
10
+ warnings.warn(
11
+ "Importing 'mcp_registry' from the repository root is deprecated; "
12
+ "use 'import latticeai.core.mcp_registry' instead. "
13
+ "The root shim will be removed in a future major release.",
14
+ DeprecationWarning,
15
+ stacklevel=2,
16
+ )
17
+
18
+ import latticeai.core.mcp_registry as _impl # noqa: E402
10
19
 
11
20
  sys.modules[__name__] = _impl
package/p_reinforce.py CHANGED
@@ -1,7 +1,16 @@
1
1
  """Compatibility shim for the historical root P-Reinforce module."""
2
2
 
3
3
  import sys
4
+ import warnings
4
5
 
5
- from latticeai.services import p_reinforce as _impl
6
+ warnings.warn(
7
+ "Importing 'p_reinforce' from the repository root is deprecated; "
8
+ "use 'from latticeai.services import p_reinforce' 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 p_reinforce as _impl # noqa: E402
6
15
 
7
16
  sys.modules[__name__] = _impl