ltcai 9.5.0 → 9.6.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 (49) hide show
  1. package/README.md +57 -43
  2. package/docs/CHANGELOG.md +32 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
  4. package/docs/DEVELOPMENT.md +8 -8
  5. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  6. package/docs/ONBOARDING.md +2 -2
  7. package/docs/OPERATIONS.md +1 -1
  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/docs/mcp-tools.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/runtime/multi_agent.py +1 -1
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/change_proposals.py +60 -0
  16. package/latticeai/api/chat_agent_http.py +2 -0
  17. package/latticeai/app_factory.py +20 -0
  18. package/latticeai/cli/entrypoint.py +3 -1
  19. package/latticeai/core/agent.py +127 -13
  20. package/latticeai/core/agent_eval.py +260 -0
  21. package/latticeai/core/agent_trace.py +104 -0
  22. package/latticeai/core/legacy_compatibility.py +1 -1
  23. package/latticeai/core/marketplace.py +1 -1
  24. package/latticeai/core/tool_governor.py +101 -0
  25. package/latticeai/core/workspace_os.py +1 -1
  26. package/latticeai/services/architecture_readiness.py +1 -1
  27. package/latticeai/services/change_proposals.py +270 -0
  28. package/latticeai/services/product_readiness.py +1 -1
  29. package/latticeai/services/review_queue.py +4 -1
  30. package/latticeai/tools/__init__.py +6 -1
  31. package/package.json +1 -1
  32. package/scripts/agent_eval.py +46 -0
  33. package/scripts/check_current_release_docs.mjs +2 -1
  34. package/src-tauri/Cargo.lock +1 -1
  35. package/src-tauri/Cargo.toml +1 -1
  36. package/src-tauri/tauri.conf.json +1 -1
  37. package/static/app/asset-manifest.json +11 -11
  38. package/static/app/assets/{Act-Cdfqx4wN.js → Act-BkOEmwBi.js} +1 -1
  39. package/static/app/assets/{Brain-w_tAuyg6.js → Brain-C9ITUsQ_.js} +1 -1
  40. package/static/app/assets/{Capture-iJwi9LmS.js → Capture-C-ppTeud.js} +1 -1
  41. package/static/app/assets/{Library-Do-jjhzi.js → Library-CGQbgWTu.js} +1 -1
  42. package/static/app/assets/{System-DFg_q3E6.js → System-djmj0n2_.js} +1 -1
  43. package/static/app/assets/{index-BN6HIWVC.css → index-85wQvEie.css} +1 -1
  44. package/static/app/assets/index-AF0-4XVv.js +18 -0
  45. package/static/app/assets/{primitives-ZTUlU7pR.js → primitives-jbb2qv4Q.js} +1 -1
  46. package/static/app/assets/{textarea-CMfhqPhE.js → textarea-CFoo0OxJ.js} +1 -1
  47. package/static/app/index.html +2 -2
  48. package/static/sw.js +1 -1
  49. package/static/app/assets/index-aJuRi-Xo.js +0 -17
@@ -0,0 +1,104 @@
1
+ """Structured observability for the single-agent reasoning loop (v9.6.0).
2
+
3
+ The PLAN → EXECUTE → VERIFY state machine in :mod:`latticeai.core.agent`
4
+ already keeps a human-readable transcript, but the transcript mixes model
5
+ output, tool results, and control decisions into one list — you cannot ask
6
+ "how many parse failures were recovered?" or "which repairs did the weak
7
+ model need?" without re-parsing it.
8
+
9
+ :class:`LoopTrace` is the machine-readable side channel: every phase records
10
+ typed events (llm_call, parse_error, repair, correction, tool call outcome,
11
+ blocked action, retry, rollback), and :meth:`LoopTrace.summary` reduces them
12
+ to the counters an evaluation harness or the API response can consume
13
+ directly. The trace is pure data — no I/O, no clock dependency beyond an
14
+ injectable timestamp function — so unit tests and the deterministic agent
15
+ evaluation harness can assert on it exactly.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Callable, Dict, List, Optional
21
+
22
+ from latticeai.core.timeutil import now_iso
23
+
24
+ _MAX_EVENTS = 500
25
+
26
+
27
+ class LoopTrace:
28
+ """Typed event stream + summary counters for one agent run."""
29
+
30
+ def __init__(self, clock: Optional[Callable[[], str]] = None) -> None:
31
+ self._clock = clock or now_iso
32
+ self.events: List[Dict[str, Any]] = []
33
+ self.truncated = 0
34
+
35
+ def record(self, phase: str, kind: str, **details: Any) -> None:
36
+ if len(self.events) >= _MAX_EVENTS:
37
+ self.truncated += 1
38
+ return
39
+ event: Dict[str, Any] = {"phase": phase, "kind": kind, "at": self._clock()}
40
+ for key, value in details.items():
41
+ if value is not None:
42
+ event[key] = value
43
+ self.events.append(event)
44
+
45
+ # ── typed helpers keep call sites terse and the vocabulary closed ────
46
+
47
+ def llm_call(self, phase: str, *, model: Optional[str] = None) -> None:
48
+ self.record(phase, "llm_call", model=model)
49
+
50
+ def parse_error(self, phase: str, *, error: str, recovered: bool) -> None:
51
+ self.record(phase, "parse_error", error=error[:200], recovered=recovered)
52
+
53
+ def repair(self, phase: str, *, repairs: List[str]) -> None:
54
+ if repairs:
55
+ self.record(phase, "repair", repairs=list(repairs))
56
+
57
+ def correction(self, phase: str, *, hint: str) -> None:
58
+ self.record(phase, "correction", hint=hint[:200])
59
+
60
+ def tool(self, phase: str, *, name: str, outcome: str, risk: Optional[str] = None) -> None:
61
+ self.record(phase, "tool", name=name, outcome=outcome, risk=risk)
62
+
63
+ def decision(self, phase: str, *, decision: str, **details: Any) -> None:
64
+ self.record(phase, "decision", decision=decision, **details)
65
+
66
+ def retry(self, phase: str, *, attempt: int) -> None:
67
+ self.record(phase, "retry", attempt=attempt)
68
+
69
+ # ── reduction ────────────────────────────────────────────────────────
70
+
71
+ def summary(self) -> Dict[str, Any]:
72
+ counts: Dict[str, int] = {}
73
+ tool_outcomes: Dict[str, int] = {}
74
+ repairs: Dict[str, int] = {}
75
+ parse_errors = 0
76
+ parse_recovered = 0
77
+ for event in self.events:
78
+ kind = event["kind"]
79
+ counts[kind] = counts.get(kind, 0) + 1
80
+ if kind == "tool":
81
+ outcome = str(event.get("outcome") or "unknown")
82
+ tool_outcomes[outcome] = tool_outcomes.get(outcome, 0) + 1
83
+ elif kind == "parse_error":
84
+ parse_errors += 1
85
+ if event.get("recovered"):
86
+ parse_recovered += 1
87
+ elif kind == "repair":
88
+ for name in event.get("repairs") or []:
89
+ repairs[name] = repairs.get(name, 0) + 1
90
+ return {
91
+ "events": len(self.events),
92
+ "truncated_events": self.truncated,
93
+ "kind_counts": counts,
94
+ "llm_calls": counts.get("llm_call", 0),
95
+ "parse_errors": parse_errors,
96
+ "parse_recovered": parse_recovered,
97
+ "corrections": counts.get("correction", 0),
98
+ "retries": counts.get("retry", 0),
99
+ "tool_outcomes": tool_outcomes,
100
+ "repairs": repairs,
101
+ }
102
+
103
+
104
+ __all__ = ["LoopTrace"]
@@ -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.5.0"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.6.0"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -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.5.0"
14
+ MARKETPLACE_VERSION = "9.6.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17
 
@@ -0,0 +1,101 @@
1
+ """Central change-class governor for tool calls (v9.6.0).
2
+
3
+ The tool registry already answers "how risky is this tool?" (read / write /
4
+ exec, destructive, auto-approve). What it could not answer is the question
5
+ users actually care about: **does this call create something new, or does it
6
+ change/remove something that already exists?**
7
+
8
+ The governor adds that dimension in one place:
9
+
10
+ * ``read`` — no state change.
11
+ * ``additive`` — creates new content only (new file, new note, new node).
12
+ Low friction: runs under the existing gates without extra ceremony.
13
+ * ``mutation`` — rewrites existing content (overwrite / edit of an existing
14
+ file). Proposal-first: instead of applying silently, the change is staged
15
+ as a review proposal the user merges deliberately.
16
+ * ``destructive`` — removes existing content. Always proposal-first.
17
+
18
+ Classification is deterministic and injectable (``path_exists``), so the
19
+ agent loop, the chat file path, and tests all share exactly one policy.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Callable, Dict, Mapping, Optional
25
+
26
+ CHANGE_READ = "read"
27
+ CHANGE_ADDITIVE = "additive"
28
+ CHANGE_MUTATION = "mutation"
29
+ CHANGE_DESTRUCTIVE = "destructive"
30
+ CHANGE_EXEC = "exec"
31
+
32
+ # Tools whose effect depends on whether the target already exists.
33
+ _TARGET_WRITE_TOOLS = frozenset({
34
+ "write_file", "local_write",
35
+ "create_docx", "create_xlsx", "create_pptx", "create_pdf",
36
+ })
37
+ # Tools that always rewrite existing content.
38
+ _ALWAYS_MUTATION_TOOLS = frozenset({"edit_file"})
39
+ # Tools that remove existing content.
40
+ _DESTRUCTIVE_TOOLS = frozenset({"delete_file", "remove_file", "clear_history"})
41
+ # Additive-only knowledge writes (append-style, never rewrite).
42
+ _ADDITIVE_TOOLS = frozenset({
43
+ "knowledge_save", "obsidian_save", "todo_write", "create_web_project",
44
+ "knowledge_graph_ingest",
45
+ })
46
+
47
+
48
+ def classify_tool_call(
49
+ name: str,
50
+ args: Mapping[str, Any],
51
+ *,
52
+ policy: Optional[Mapping[str, Any]] = None,
53
+ path_exists: Optional[Callable[[str], bool]] = None,
54
+ ) -> Dict[str, Any]:
55
+ """Classify one tool call into a change class + proposal requirement."""
56
+ risk = str((policy or {}).get("risk") or "")
57
+ change = CHANGE_READ
58
+ reason = "read-only tool"
59
+
60
+ if name in _DESTRUCTIVE_TOOLS or (policy or {}).get("destructive"):
61
+ change = CHANGE_DESTRUCTIVE
62
+ reason = "removes existing content"
63
+ elif name in _ALWAYS_MUTATION_TOOLS:
64
+ change = CHANGE_MUTATION
65
+ reason = "edits existing content in place"
66
+ elif name in _TARGET_WRITE_TOOLS:
67
+ path = str(args.get("path") or args.get("filename") or "")
68
+ exists = bool(path and path_exists and path_exists(path))
69
+ change = CHANGE_MUTATION if exists else CHANGE_ADDITIVE
70
+ reason = (
71
+ "overwrites an existing file" if exists else "creates a new file"
72
+ )
73
+ elif name in _ADDITIVE_TOOLS:
74
+ change = CHANGE_ADDITIVE
75
+ reason = "adds new content only"
76
+ elif risk.startswith("read"):
77
+ change = CHANGE_READ
78
+ reason = "read-only tool"
79
+ elif risk == "exec":
80
+ change = CHANGE_EXEC
81
+ reason = "executes an action (approval-gated, not proposal-based)"
82
+ elif risk in {"write", "write_scoped"}:
83
+ change = CHANGE_ADDITIVE
84
+ reason = "write tool without an existing target"
85
+
86
+ return {
87
+ "tool": name,
88
+ "change_class": change,
89
+ "proposal_required": change in {CHANGE_MUTATION, CHANGE_DESTRUCTIVE},
90
+ "reason": reason,
91
+ }
92
+
93
+
94
+ __all__ = [
95
+ "CHANGE_READ",
96
+ "CHANGE_ADDITIVE",
97
+ "CHANGE_MUTATION",
98
+ "CHANGE_DESTRUCTIVE",
99
+ "CHANGE_EXEC",
100
+ "classify_tool_call",
101
+ ]
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "remove_skill_directory",
50
50
  ]
51
51
 
52
- WORKSPACE_OS_VERSION = "9.5.0"
52
+ WORKSPACE_OS_VERSION = "9.6.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
@@ -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.6.0"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -0,0 +1,270 @@
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
+ ) -> Optional[Dict[str, Any]]:
75
+ """Governor port for the agent loop.
76
+
77
+ Returns ``None`` to fall through to the existing gates, or a verdict:
78
+ ``{"decision": "allow_additive"}`` (execute without extra approval) /
79
+ ``{"decision": "proposed", "proposal": {...}}`` (staged for review).
80
+ """
81
+ if name not in {"write_file", "edit_file"}:
82
+ return None
83
+ verdict = classify_tool_call(
84
+ name, args, policy=policy, path_exists=self._path_exists
85
+ )
86
+ if verdict["change_class"] == "additive":
87
+ return {"decision": "allow_additive", "classification": verdict}
88
+ if not verdict["proposal_required"]:
89
+ return None
90
+
91
+ path = str(args.get("path") or "")
92
+ after = self._staged_content(name, args)
93
+ if after is None:
94
+ # The edit cannot be computed deterministically (e.g. old_string
95
+ # not found) — let the normal tool path surface the real error.
96
+ return None
97
+ try:
98
+ proposal = self.propose_file_update(
99
+ path=path,
100
+ new_content=after,
101
+ proposed_by="agent",
102
+ reason=verdict["reason"],
103
+ user_email=user_email,
104
+ workspace_id=workspace_id,
105
+ )
106
+ except Exception:
107
+ LOGGER.exception("change proposal staging failed")
108
+ return None
109
+ return {"decision": "proposed", "classification": verdict, "proposal": proposal}
110
+
111
+ def _path_exists(self, path: str) -> bool:
112
+ try:
113
+ return self._resolve_path(path).is_file()
114
+ except Exception:
115
+ return False
116
+
117
+ def _read_before(self, path: str) -> str:
118
+ try:
119
+ target = self._resolve_path(path)
120
+ if not target.is_file():
121
+ return ""
122
+ raw = target.read_bytes()[:_MAX_STAGED_BYTES]
123
+ return raw.decode("utf-8", errors="replace")
124
+ except Exception:
125
+ LOGGER.exception("change proposal read failed")
126
+ return ""
127
+
128
+ def _staged_content(self, name: str, args: Dict[str, Any]) -> Optional[str]:
129
+ if name == "write_file":
130
+ return str(args.get("content") or "")[:_MAX_STAGED_BYTES]
131
+ if name == "edit_file":
132
+ before = self._read_before(str(args.get("path") or ""))
133
+ old = str(args.get("old_string") or "")
134
+ new = str(args.get("new_string") or "")
135
+ if not old or old not in before:
136
+ return None
137
+ if args.get("replace_all"):
138
+ return before.replace(old, new)[:_MAX_STAGED_BYTES]
139
+ if before.count(old) != 1:
140
+ return None
141
+ return before.replace(old, new, 1)[:_MAX_STAGED_BYTES]
142
+ return None
143
+
144
+ # ── proposal creation ────────────────────────────────────────────────
145
+
146
+ def propose_file_update(
147
+ self,
148
+ *,
149
+ path: str,
150
+ new_content: str,
151
+ proposed_by: str = "agent",
152
+ reason: str = "",
153
+ user_email: Optional[str] = None,
154
+ workspace_id: Optional[str] = None,
155
+ ) -> Dict[str, Any]:
156
+ before = self._read_before(path)
157
+ diff = _unified_diff(before, new_content, path)
158
+ tier = "small" if len(diff) <= _SMALL_TIER_DIFF_LINES else "large"
159
+ item = self._review_queue.create(
160
+ title=f"파일 수정 제안: {path}",
161
+ summary=reason or "기존 파일을 변경하는 작업이라 검토 후 적용됩니다.",
162
+ source="change_proposal",
163
+ kind="file_update",
164
+ payload={
165
+ "path": path,
166
+ "diff": diff,
167
+ "new_content": new_content[:_MAX_STAGED_BYTES],
168
+ "tier": tier,
169
+ "before_bytes": len(before.encode("utf-8")),
170
+ "after_bytes": len(new_content.encode("utf-8")),
171
+ },
172
+ provenance={"proposed_by": proposed_by, "reason": reason},
173
+ user_email=user_email,
174
+ workspace_id=workspace_id,
175
+ )
176
+ self._audit(
177
+ "change_proposal_created", user_email=user_email,
178
+ proposal_id=item.get("id"), path=path, kind="file_update", tier=tier,
179
+ )
180
+ return item
181
+
182
+ def propose_file_delete(
183
+ self,
184
+ *,
185
+ path: str,
186
+ proposed_by: str = "agent",
187
+ reason: str = "",
188
+ user_email: Optional[str] = None,
189
+ workspace_id: Optional[str] = None,
190
+ ) -> Dict[str, Any]:
191
+ before = self._read_before(path)
192
+ item = self._review_queue.create(
193
+ title=f"파일 삭제 제안: {path}",
194
+ summary=reason or "기존 파일을 삭제하는 작업이라 검토 후 적용됩니다.",
195
+ source="change_proposal",
196
+ kind="file_delete",
197
+ payload={
198
+ "path": path,
199
+ "diff": _unified_diff(before, "", path),
200
+ "tier": "large",
201
+ "before_bytes": len(before.encode("utf-8")),
202
+ "after_bytes": 0,
203
+ },
204
+ provenance={"proposed_by": proposed_by, "reason": reason},
205
+ user_email=user_email,
206
+ workspace_id=workspace_id,
207
+ )
208
+ self._audit(
209
+ "change_proposal_created", user_email=user_email,
210
+ proposal_id=item.get("id"), path=path, kind="file_delete", tier="large",
211
+ )
212
+ return item
213
+
214
+ # ── listing / apply / reject ─────────────────────────────────────────
215
+
216
+ def pending(
217
+ self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None
218
+ ) -> Dict[str, Any]:
219
+ listed = self._review_queue.list(
220
+ workspace_id=workspace_id, user_email=user_email,
221
+ status="pending", source="change_proposal",
222
+ )
223
+ items = listed.get("items") or []
224
+ return {
225
+ "items": items,
226
+ "count": len(items),
227
+ "contract": {
228
+ "additive_writes": "auto",
229
+ "mutations": "proposal",
230
+ "deletions": "proposal",
231
+ "applied_content": "exactly_as_reviewed",
232
+ },
233
+ }
234
+
235
+ def approve_and_apply(
236
+ self, item_id: str, *, user_email: Optional[str] = None,
237
+ workspace_id: Optional[str] = None,
238
+ ) -> Dict[str, Any]:
239
+ item = self._review_queue.get(item_id, workspace_id=workspace_id)
240
+ payload = item.get("payload") or {}
241
+ kind = item.get("kind")
242
+ path = str(payload.get("path") or "")
243
+ target = self._resolve_path(path)
244
+ if kind == "file_update":
245
+ target.parent.mkdir(parents=True, exist_ok=True)
246
+ target.write_text(str(payload.get("new_content") or ""), encoding="utf-8")
247
+ elif kind == "file_delete":
248
+ if target.is_file():
249
+ target.unlink()
250
+ else:
251
+ raise ValueError(f"unknown change proposal kind: {kind}")
252
+ approved = self._review_queue.approve(item_id, workspace_id=workspace_id)
253
+ self._audit(
254
+ "change_proposal_applied", user_email=user_email,
255
+ proposal_id=item_id, path=path, kind=kind,
256
+ )
257
+ return {"item": approved, "applied": True, "path": path, "kind": kind}
258
+
259
+ def reject(
260
+ self, item_id: str, *, user_email: Optional[str] = None,
261
+ workspace_id: Optional[str] = None,
262
+ ) -> Dict[str, Any]:
263
+ dismissed = self._review_queue.dismiss(item_id, workspace_id=workspace_id)
264
+ self._audit(
265
+ "change_proposal_rejected", user_email=user_email, proposal_id=item_id,
266
+ )
267
+ return {"item": dismissed, "applied": False}
268
+
269
+
270
+ __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.6.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] = {
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "9.5.0",
3
+ "version": "9.6.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env python3
2
+ """Agent-loop evaluation gate (v9.6.0).
3
+
4
+ Runs the deterministic scenario suite in ``latticeai.core.agent_eval``
5
+ against the real SingleAgentRuntime state machine (scripted model, fake
6
+ ports) and fails the release when any scenario regresses.
7
+
8
+ Usage: .venv/bin/python scripts/agent_eval.py [--verbose]
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from latticeai.core.agent_eval import run_agent_eval # noqa: E402
20
+
21
+
22
+ def main() -> int:
23
+ verbose = "--verbose" in sys.argv
24
+ report = run_agent_eval()
25
+ headline = {
26
+ "scenarios": report["scenarios"],
27
+ "passed": report["passed"],
28
+ "success_rate": report["success_rate"],
29
+ "parse_errors": report["parse_errors"],
30
+ "parse_recovered": report["parse_recovered"],
31
+ "recovery_rate": report["recovery_rate"],
32
+ }
33
+ if verbose:
34
+ print(json.dumps(report, ensure_ascii=False, indent=2))
35
+ failures = [r for r in report["results"] if not r["ok"]]
36
+ if failures:
37
+ print(f"agent-loop-eval: FAIL {headline}")
38
+ for result in failures:
39
+ print(f" ✗ {result['name']}: {'; '.join(result['failures'])}")
40
+ return 1
41
+ print(f"agent-loop-eval: OK {headline}")
42
+ return 0
43
+
44
+
45
+ if __name__ == "__main__":
46
+ raise SystemExit(main())
@@ -6,11 +6,12 @@ const root = process.cwd();
6
6
  const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
7
7
  const version = pkg.version;
8
8
  const releaseDir = `output/release/v${version}`;
9
- const releaseTheme = "Command Center";
9
+ const releaseTheme = "Trusted Agent Loop";
10
10
  const title = `${version} — ${releaseTheme}`;
11
11
  const escapedVersion = version.replaceAll(".", "\\.");
12
12
 
13
13
  const currentReleaseFiles = [
14
+ "AGENTS.md",
14
15
  "README.md",
15
16
  "ARCHITECTURE.md",
16
17
  "FEATURE_STATUS.md",
@@ -1584,7 +1584,7 @@ dependencies = [
1584
1584
 
1585
1585
  [[package]]
1586
1586
  name = "lattice-ai-desktop"
1587
- version = "9.5.0"
1587
+ version = "9.6.0"
1588
1588
  dependencies = [
1589
1589
  "plist",
1590
1590
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "9.5.0"
3
+ version = "9.6.0"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "9.5.0",
4
+ "version": "9.6.0",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",