ltcai 9.9.4 → 9.9.9

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 (138) hide show
  1. package/README.md +43 -42
  2. package/docs/CHANGELOG.md +228 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +3 -1
  7. package/docs/PERMISSION_MODE.md +107 -0
  8. package/docs/SURFACE_PARITY.md +45 -9
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/context.py +39 -4
  14. package/lattice_brain/graph/_kg_common.py +189 -14
  15. package/lattice_brain/graph/curator.py +62 -0
  16. package/lattice_brain/graph/discovery.py +76 -0
  17. package/lattice_brain/graph/ingest.py +37 -7
  18. package/lattice_brain/graph/rerank.py +160 -0
  19. package/lattice_brain/graph/retrieval.py +28 -442
  20. package/lattice_brain/graph/retrieval_reads.py +459 -0
  21. package/lattice_brain/graph/retrieval_vector.py +10 -0
  22. package/lattice_brain/graph/store.py +2 -0
  23. package/lattice_brain/ingestion.py +9 -106
  24. package/lattice_brain/ingestion_jobs.py +133 -0
  25. package/lattice_brain/quality.py +26 -6
  26. package/lattice_brain/runtime/multi_agent.py +1 -1
  27. package/latticeai/__init__.py +1 -1
  28. package/latticeai/api/brain_intelligence.py +8 -0
  29. package/latticeai/api/chat.py +9 -0
  30. package/latticeai/api/chat_agent_http.py +206 -48
  31. package/latticeai/api/chat_contracts.py +10 -1
  32. package/latticeai/api/chat_documents.py +16 -0
  33. package/latticeai/api/evidence_actions.py +48 -0
  34. package/latticeai/api/permission_mode.py +61 -0
  35. package/latticeai/api/project_sessions.py +112 -0
  36. package/latticeai/api/voice_capture.py +89 -0
  37. package/latticeai/app_factory.py +53 -0
  38. package/latticeai/core/agent.py +536 -60
  39. package/latticeai/core/agent_permission.py +160 -0
  40. package/latticeai/core/agent_profiles.py +110 -0
  41. package/latticeai/core/artifact_ledger.py +109 -0
  42. package/latticeai/core/context_builder.py +101 -2
  43. package/latticeai/core/legacy_compatibility.py +1 -1
  44. package/latticeai/core/marketplace.py +1 -1
  45. package/latticeai/core/permission_mode.py +296 -0
  46. package/latticeai/core/project_sessions.py +337 -0
  47. package/latticeai/core/run_explain.py +421 -0
  48. package/latticeai/core/run_store.py +7 -0
  49. package/latticeai/core/workspace_os.py +15 -80
  50. package/latticeai/core/workspace_review_items.py +107 -0
  51. package/latticeai/integrations/telegram_bot.py +270 -23
  52. package/latticeai/runtime/chat_wiring.py +8 -0
  53. package/latticeai/runtime/context_runtime.py +11 -0
  54. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  55. package/latticeai/runtime/router_registration.py +9 -0
  56. package/latticeai/services/app_context.py +3 -0
  57. package/latticeai/services/architecture_readiness.py +1 -1
  58. package/latticeai/services/brain_intelligence.py +105 -0
  59. package/latticeai/services/evidence_actions.py +254 -0
  60. package/latticeai/services/funnel_metrics.py +111 -16
  61. package/latticeai/services/local_knowledge.py +26 -0
  62. package/latticeai/services/memory_service.py +12 -0
  63. package/latticeai/services/permission_mode_service.py +170 -0
  64. package/latticeai/services/product_readiness.py +1 -1
  65. package/latticeai/services/tool_dispatch.py +129 -4
  66. package/latticeai/services/voice_capture.py +189 -0
  67. package/package.json +6 -3
  68. package/scripts/check_bundle_budget.mjs +11 -0
  69. package/scripts/check_current_release_docs.mjs +1 -1
  70. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  71. package/scripts/run_sidecar_e2e.mjs +159 -0
  72. package/skills/meeting_notes/SKILL.md +82 -0
  73. package/skills/meeting_notes/examples.md +41 -0
  74. package/skills/meeting_notes/risk.json +9 -0
  75. package/skills/meeting_notes/schema.json +55 -0
  76. package/skills/weekly_review/SKILL.md +75 -0
  77. package/skills/weekly_review/examples.md +39 -0
  78. package/skills/weekly_review/risk.json +9 -0
  79. package/skills/weekly_review/schema.json +55 -0
  80. package/src-tauri/Cargo.lock +1 -1
  81. package/src-tauri/Cargo.toml +1 -1
  82. package/src-tauri/tauri.conf.json +1 -1
  83. package/static/app/asset-manifest.json +37 -29
  84. package/static/app/assets/Act-BFbpl2_Y.js +1 -0
  85. package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
  86. package/static/app/assets/Brain-DGrV0heZ.js +321 -0
  87. package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
  88. package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
  89. package/static/app/assets/Capture-DRaf1zLH.js +1 -0
  90. package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
  91. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
  92. package/static/app/assets/Library-BVtrSt12.js +1 -0
  93. package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
  94. package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
  95. package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
  96. package/static/app/assets/System-B4nQDy8a.js +1 -0
  97. package/static/app/assets/{bot-nB_buEZD.js → bot-DtKkLfXj.js} +1 -1
  98. package/static/app/assets/brain-wVV63_1z.js +1 -0
  99. package/static/app/assets/button-BTxZDMFJ.js +1 -0
  100. package/static/app/assets/{circle-pause-DLNw6Ucp.js → circle-pause--qxF9-EO.js} +1 -1
  101. package/static/app/assets/{circle-play-B-IsFL1y.js → circle-play-89x3S6jm.js} +1 -1
  102. package/static/app/assets/{cpu-CEPBHaBl.js → cpu-DTUI63vo.js} +1 -1
  103. package/static/app/assets/download-EJS5_O0f.js +1 -0
  104. package/static/app/assets/{folder-open-hmN0N9cX.js → folder-open-BL9zYTnc.js} +1 -1
  105. package/static/app/assets/{hard-drive-CBV_B_Yd.js → hard-drive-BFnwi1Lu.js} +1 -1
  106. package/static/app/assets/index-BFtDYI6l.js +10 -0
  107. package/static/app/assets/index-BY9bpxyx.css +2 -0
  108. package/static/app/assets/input-DEz5UjBz.js +1 -0
  109. package/static/app/assets/{network-jE42eKfT.js → network-zKa3jAwl.js} +1 -1
  110. package/static/app/assets/primitives-jNrZm2O9.js +1 -0
  111. package/static/app/assets/search-ku6-_6wY.js +1 -0
  112. package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
  113. package/static/app/assets/textarea-DpcQkn75.js +1 -0
  114. package/static/app/assets/{useFocusTrap-B7RPGfFy.js → useFocusTrap-58TNCONM.js} +1 -1
  115. package/static/app/assets/{navigation-Bot0hvuv.js → useQuery-CcVlXlBd.js} +1 -1
  116. package/static/app/assets/users-C0cFMj6o.js +1 -0
  117. package/static/app/assets/utils-DYf7pqrR.js +7 -0
  118. package/static/app/assets/workspace-CjthHsLN.js +1 -0
  119. package/static/app/index.html +4 -4
  120. package/static/sw.js +1 -1
  121. package/static/app/assets/Act-BCmTU0E2.js +0 -1
  122. package/static/app/assets/Brain-CTnjox7w.js +0 -321
  123. package/static/app/assets/BrainHome-BJ3sFNX0.js +0 -2
  124. package/static/app/assets/BrainSignals-C52lwZVD.js +0 -1
  125. package/static/app/assets/Capture-B6vBhFa3.js +0 -1
  126. package/static/app/assets/CommandPalette-90u9FWFH.js +0 -1
  127. package/static/app/assets/Library-pKCK0_tk.js +0 -1
  128. package/static/app/assets/LivingBrain-Jlf2wFqI.js +0 -1
  129. package/static/app/assets/ProductFlow-yg1fKP1P.js +0 -1
  130. package/static/app/assets/ReviewCard-DWvD7n9h.js +0 -3
  131. package/static/app/assets/System-BAEuHqNY.js +0 -1
  132. package/static/app/assets/index-7FAfYm4v.css +0 -2
  133. package/static/app/assets/index-DrmOCySv.js +0 -10
  134. package/static/app/assets/input-gtVCg-ll.js +0 -1
  135. package/static/app/assets/primitives-CX2Komon.js +0 -1
  136. package/static/app/assets/shield-alert-BftATuAA.js +0 -1
  137. package/static/app/assets/textarea-CiMJfOSI.js +0 -1
  138. package/static/app/assets/utils-SJUNVOj5.js +0 -7
@@ -0,0 +1,107 @@
1
+ """Review-queue persistence extracted from WorkspaceOSStore (v9.9.6).
2
+
3
+ Workspace-scoped suggestion inbox: automation/trigger runs write drafts here
4
+ for the user to approve/dismiss/snooze. **Persistence only** — the transition
5
+ policy lives in :mod:`latticeai.services.review_queue`.
6
+
7
+ Behaviour-preserving move (review 2026-07-27 P2 #8 "대형 모듈 추가 분해 —
8
+ 동작 보존 이동 우선"): the store delegates the same method names to this
9
+ collaborator, following the existing ``WorkspaceRuns`` / ``WorkspaceGraphTrace``
10
+ pattern.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from .timeutil import now_iso as _now
17
+ from .workspace_os_utils import _json_hash, _listify
18
+
19
+
20
+ class WorkspaceReviewItems:
21
+ def __init__(self, store: Any):
22
+ self._store = store
23
+
24
+ def __getattr__(self, name: str) -> Any:
25
+ return getattr(self._store, name)
26
+
27
+ def create_review_item(
28
+ self,
29
+ *,
30
+ title: str,
31
+ summary: str = "",
32
+ source: str = "workflow_run",
33
+ kind: str = "suggestion",
34
+ payload: Optional[Dict[str, Any]] = None,
35
+ provenance: Optional[Dict[str, Any]] = None,
36
+ user_email: Optional[str] = None,
37
+ workspace_id: Optional[str] = None,
38
+ ) -> Dict[str, Any]:
39
+ if not str(title or "").strip():
40
+ raise ValueError("title is required")
41
+ state = self.load_state()
42
+ resolved_workspace = self._resolve_scope(workspace_id, state)
43
+ now = _now()
44
+ item = {
45
+ "id": f"review-{_json_hash([title, source, kind, user_email, now])[:16]}",
46
+ "status": "pending",
47
+ "title": title,
48
+ "summary": summary or "",
49
+ "source": source or "workflow_run",
50
+ "kind": kind or "suggestion",
51
+ "payload": dict(payload or {}),
52
+ "provenance": dict(provenance or {}),
53
+ "snoozed_until": None,
54
+ "user_email": user_email,
55
+ "workspace_id": resolved_workspace,
56
+ "created_at": now,
57
+ "updated_at": now,
58
+ }
59
+ state.setdefault("review_items", []).append(item)
60
+ self.save_state(state)
61
+ self.record_timeline_event(
62
+ "review", "review_item_created",
63
+ {"item_id": item["id"], "source": item["source"], "kind": item["kind"]},
64
+ workspace_id=resolved_workspace,
65
+ )
66
+ return item
67
+
68
+ def list_review_items(
69
+ self, *, workspace_id: Optional[str] = None, user_email: Optional[str] = None,
70
+ source: Optional[str] = None,
71
+ ) -> List[Dict[str, Any]]:
72
+ items = self._scoped(_listify(self.load_state().get("review_items")), workspace_id)
73
+ if user_email:
74
+ items = [item for item in items if item.get("user_email") in {None, user_email}]
75
+ if source:
76
+ items = [item for item in items if item.get("source") == source]
77
+ return list(reversed(items))
78
+
79
+ def get_review_item(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
80
+ item = next(
81
+ (it for it in _listify(self.load_state().get("review_items")) if it.get("id") == item_id),
82
+ None,
83
+ )
84
+ if item is None or (workspace_id and self._record_workspace(item) != str(workspace_id)):
85
+ raise FileNotFoundError(item_id)
86
+ return item
87
+
88
+ def update_review_item(
89
+ self, item_id: str, *, workspace_id: Optional[str] = None, **fields: Any,
90
+ ) -> Dict[str, Any]:
91
+ state = self.load_state()
92
+ item = next((it for it in _listify(state.get("review_items")) if it.get("id") == item_id), None)
93
+ if item is None or (workspace_id and self._record_workspace(item) != str(workspace_id)):
94
+ raise FileNotFoundError(item_id)
95
+ for key, value in fields.items():
96
+ item[key] = value
97
+ item["updated_at"] = _now()
98
+ self.save_state(state)
99
+ self.record_timeline_event(
100
+ "review", "review_item_updated",
101
+ {"item_id": item_id, "status": item.get("status")},
102
+ workspace_id=self._record_workspace(item),
103
+ )
104
+ return item
105
+
106
+
107
+ __all__ = ["WorkspaceReviewItems"]
@@ -36,11 +36,13 @@ STATUS_URL = f"{BASE_URL}/status"
36
36
  MODELS_URL = f"{BASE_URL}/models"
37
37
  GRAPH_STATS_URL = f"{BASE_URL}/knowledge-graph/stats"
38
38
  UPLOAD_DOC_URL = f"{BASE_URL}/upload/document"
39
+ PROPOSALS_URL = f"{BASE_URL}/api/proposals"
39
40
 
40
41
  AGENT_RESUME_URL = f"{BASE_URL}/agent/resume"
41
42
  AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
42
43
 
43
- # Pending plan approvals: context_id (chat_id, executing_model, reviewing_model)
44
+ # Pending plan approvals keyed by run_id (or legacy context_id).
45
+ # Values: chat_id, models, and the resume credentials (token and/or legacy).
44
46
  _bot_pending_plans: dict[str, dict] = {}
45
47
  MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
46
48
  INVITE_CODE = env_value("LATTICEAI_INVITE_CODE")
@@ -296,6 +298,9 @@ MAIN_MENU = {
296
298
  {"text": "🔗 웹 UI 열기", "callback_data": "cmd:web"},
297
299
  {"text": "🔌 MCP 도구 목록", "callback_data": "cmd:mcp"},
298
300
  ],
301
+ [
302
+ {"text": "🗂 변경 제안 검토", "callback_data": "cmd:review"},
303
+ ],
299
304
  ]
300
305
  }
301
306
 
@@ -664,6 +669,176 @@ def collect_generated_files(agent_data):
664
669
  files.append((path, target))
665
670
  return files
666
671
 
672
+ def format_grounding_badge(data, *, language: str = "ko") -> str:
673
+ """Answer-grounding badge line for a `/chat` reply (v9.9.7).
674
+
675
+ Telegram used to show only the answer text, so a reply the Brain could not
676
+ ground looked identical to one built on real sources. This renders exactly
677
+ the verdict the server issued — and reports an absent verdict as unknown
678
+ rather than promoting it to "근거 있음".
679
+ """
680
+ if not isinstance(data, dict):
681
+ return ""
682
+ grounding = data.get("grounding")
683
+ if not isinstance(grounding, dict):
684
+ return ""
685
+ status = str(grounding.get("status") or "")
686
+ if not status:
687
+ return ""
688
+ korean = not str(language or "ko").startswith("en")
689
+ cited = [
690
+ str(item.get("title") or "").strip()
691
+ for item in (grounding.get("cited") or [])
692
+ if isinstance(item, dict) and str(item.get("title") or "").strip()
693
+ ]
694
+ if status == "supported":
695
+ head = "✅ 근거 있음" if korean else "✅ grounded"
696
+ if cited:
697
+ head += " — " + ", ".join(cited[:3])
698
+ return head
699
+ if status in {"unsupported", "no_context"}:
700
+ head = "⚠️ 근거 없음" if korean else "⚠️ not grounded"
701
+ reason = str(grounding.get("reason") or "").strip()
702
+ return f"{head} — {reason}" if reason else head
703
+ return "❔ 근거 확인 불가" if korean else "❔ grounding unknown"
704
+
705
+
706
+ async def send_grounding_badge(client, chat_id, data):
707
+ text = format_grounding_badge(data)
708
+ if text:
709
+ await send_message(client, chat_id, text)
710
+
711
+
712
+ def format_proposals(payload) -> list:
713
+ """`GET /api/proposals` → ``[(item_id, label)]`` for the Review Center.
714
+
715
+ Rows without an id are dropped: an un-actionable row is worse than none.
716
+ """
717
+ root = payload if isinstance(payload, dict) else {}
718
+ rows = payload if isinstance(payload, list) else (root.get("items") or [])
719
+ out = []
720
+ for raw in rows if isinstance(rows, list) else []:
721
+ if not isinstance(raw, dict):
722
+ continue
723
+ item_id = str(raw.get("id") or "").strip()
724
+ if not item_id:
725
+ continue
726
+ body = raw.get("payload") if isinstance(raw.get("payload"), dict) else {}
727
+ provenance = raw.get("provenance") if isinstance(raw.get("provenance"), dict) else {}
728
+ path = str(body.get("path") or provenance.get("path") or "")
729
+ title = str(raw.get("title") or path or item_id)
730
+ change_class = str(body.get("change_class") or provenance.get("change_class") or "")
731
+ label = title if not change_class else f"{title} ({change_class})"
732
+ out.append((item_id, label))
733
+ return out
734
+
735
+
736
+ async def show_review_center(client, chat_id):
737
+ """Review Center parity (v9.9.7): staged change proposals with inline
738
+ approve/reject, using the same `/api/proposals` surface as the web app."""
739
+ try:
740
+ async with _server_client() as sc:
741
+ res = await sc.get(PROPOSALS_URL, timeout=30.0)
742
+ if res.status_code != 200:
743
+ await send_message(client, chat_id, f"검토함을 불러오지 못했습니다 ({res.status_code})")
744
+ return
745
+ items = format_proposals(res.json())
746
+ except Exception as exc:
747
+ await send_message(client, chat_id, f"검토함 조회 실패: {exc}")
748
+ return
749
+ if not items:
750
+ await send_message(client, chat_id, "🗂 검토할 변경 제안이 없습니다.")
751
+ return
752
+ lines = ["🗂 변경 제안 (승인해야 적용됩니다)"]
753
+ keyboard = []
754
+ for item_id, label in items[:8]:
755
+ lines.append(f"- {label}")
756
+ keyboard.append([
757
+ {"text": f"✅ 승인 — {label}"[:64], "callback_data": f"proposal:approve:{item_id}"[:64]},
758
+ {"text": "❌ 거절"[:64], "callback_data": f"proposal:reject:{item_id}"[:64]},
759
+ ])
760
+ if len(items) > 8:
761
+ lines.append(f"… 외 {len(items) - 8}건")
762
+ await send_message(
763
+ client, chat_id, "\n".join(lines),
764
+ reply_markup={"inline_keyboard": keyboard},
765
+ )
766
+
767
+
768
+ async def handle_proposal_callback(client, chat_id, data: str):
769
+ """Apply or reject one staged proposal.
770
+
771
+ A 409 means the target file changed since staging — nothing was written,
772
+ and the user is told exactly that instead of a silent retry.
773
+ """
774
+ try:
775
+ _, decision, item_id = data.split(":", 2)
776
+ except ValueError:
777
+ return
778
+ if not item_id:
779
+ return
780
+ suffix = "approve" if decision == "approve" else "reject"
781
+ url = f"{PROPOSALS_URL}/{item_id}/{suffix}"
782
+ try:
783
+ async with _server_client() as sc:
784
+ res = await sc.post(url, json={}, timeout=120.0)
785
+ except Exception as exc:
786
+ await send_message(client, chat_id, f"❌ 처리 실패: {exc}")
787
+ return
788
+ if res.status_code == 409:
789
+ await send_message(
790
+ client, chat_id,
791
+ "⚠️ 제안을 만든 뒤 파일이 바뀌어서 적용하지 않았습니다. 아무것도 쓰지 않았습니다.",
792
+ )
793
+ return
794
+ if res.status_code != 200:
795
+ await send_message(client, chat_id, f"❌ 서버 에러 ({res.status_code})")
796
+ return
797
+ if suffix == "approve":
798
+ try:
799
+ applied = res.json().get("path") or item_id
800
+ except Exception:
801
+ applied = item_id
802
+ await send_message(client, chat_id, f"✅ 적용했습니다: {applied}")
803
+ else:
804
+ await send_message(client, chat_id, "🚫 제안을 거절했습니다.")
805
+
806
+
807
+ def format_run_explanation(agent_data, *, language: str = "ko") -> str:
808
+ """Plain-language outcome line for a finished agent run (v9.9.6).
809
+
810
+ Telegram used to show only the final message, so a NEEDS_REVIEW run read
811
+ exactly like a success. The server already computes the honest sentence
812
+ (`explanation`); this renders it. Returns "" when there is nothing to add
813
+ — a clean, verified run gets no extra noise.
814
+ """
815
+ if not isinstance(agent_data, dict):
816
+ return ""
817
+ explanation = agent_data.get("explanation")
818
+ if not isinstance(explanation, dict):
819
+ return ""
820
+ lang = "ko" if str(language or "ko").startswith("ko") else "en"
821
+
822
+ def pick(entry):
823
+ return str(entry.get(lang) or "") if isinstance(entry, dict) else ""
824
+
825
+ details = [pick(item) for item in explanation.get("details") or []]
826
+ details = [line for line in details if line]
827
+ if explanation.get("ok") and not details:
828
+ return ""
829
+ headline = pick(explanation.get("headline"))
830
+ marker = "✅" if explanation.get("ok") else "⚠️"
831
+ lines = [f"{marker} {headline}" if headline else marker]
832
+ lines.extend(f"· {line}" for line in details[:4])
833
+ return "\n".join(lines)
834
+
835
+
836
+ async def send_run_explanation(client, chat_id, agent_data):
837
+ text = format_run_explanation(agent_data)
838
+ if text:
839
+ await send_message(client, chat_id, text)
840
+
841
+
667
842
  def collect_preview_urls(agent_data):
668
843
  urls, seen = [], set()
669
844
  for step in agent_data.get("steps", []):
@@ -711,35 +886,65 @@ async def send_generated_files(client, chat_id, generated_files):
711
886
 
712
887
  # ── Plan approval (Human-in-the-loop) ────────────────────────────────────────
713
888
 
889
+ def _approval_pause_id(data: dict) -> str:
890
+ """Stable key for a paused plan: run_id preferred, legacy context_id fallback."""
891
+ return str(data.get("run_id") or data.get("context_id") or "")
892
+
893
+
894
+ def _is_approval_pause(data: dict) -> bool:
895
+ status = str(data.get("status") or "")
896
+ return status in {"waiting_approval", "awaiting_approval"}
897
+
898
+
714
899
  async def send_plan_for_approval(client, chat_id, data: dict) -> None:
715
- """Show the agent plan to the user and present Done/Cancel buttons."""
716
- context_id = data.get("context_id", "")
717
- plan = data.get("plan", {})
718
- goal = plan.get("goal", "")
719
- steps = plan.get("steps", [])
900
+ """Show the agent plan to the user and present Done/Cancel buttons.
901
+
902
+ Handles both the modern ``awaiting_approval`` (run_id + token) and the
903
+ legacy ``waiting_approval`` / ``context_id`` wire contracts (v9.9.5
904
+ SURFACE_PARITY Telegram approval flow).
905
+ """
906
+ pause_id = _approval_pause_id(data)
907
+ if not pause_id:
908
+ await send_message(client, chat_id, "❌ 승인할 계획을 식별할 수 없습니다.")
909
+ return
910
+ plan = data.get("plan", {}) or {}
911
+ goal = plan.get("goal") or (data.get("approval") or {}).get("plan_summary") or ""
912
+ steps = plan.get("steps", []) or data.get("non_auto_steps") or []
720
913
  p_model = data.get("planning_model", "current")
721
914
  e_model = data.get("executing_model", "current")
722
915
  r_model = data.get("reviewing_model", "current")
916
+ approval = data.get("approval") or {}
917
+ expires_at = approval.get("expires_at")
723
918
 
724
919
  lines = ["📋 *플래닝 완료* — 실행 전 확인해주세요\n"]
725
920
  if goal:
726
921
  lines.append(f"*목표:* {goal}\n")
727
922
  for i, step in enumerate(steps, 1):
728
- desc = step.get("description") or step.get("action") or str(step)
923
+ if isinstance(step, dict):
924
+ desc = step.get("description") or step.get("action") or str(step)
925
+ else:
926
+ desc = str(step)
729
927
  lines.append(f"{i}. {desc}")
730
928
  lines.append(f"\n🧠 플래닝: `{p_model}`")
731
929
  lines.append(f"⚙️ 실행: `{e_model}`")
732
930
  lines.append(f"🔍 검토: `{r_model}`")
931
+ if expires_at:
932
+ lines.append(f"⏳ 승인 만료: `{expires_at}`")
733
933
 
734
- _bot_pending_plans[context_id] = {
934
+ _bot_pending_plans[pause_id] = {
735
935
  "chat_id": chat_id,
936
+ "run_id": data.get("run_id") or pause_id,
937
+ "context_id": data.get("context_id"),
938
+ "approval_token": approval.get("token"),
939
+ "legacy": data.get("status") == "waiting_approval" or bool(data.get("context_id")),
736
940
  "executing_model": data.get("executing_model"),
737
941
  "reviewing_model": data.get("reviewing_model"),
738
942
  }
739
943
 
944
+ # callback_data max 64 bytes — pause ids are token_urlsafe(16) (~22 chars).
740
945
  keyboard = {"inline_keyboard": [[
741
- {"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{context_id}"},
742
- {"text": "❌ 취소", "callback_data": f"plan:cancel:{context_id}"},
946
+ {"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{pause_id}"},
947
+ {"text": "❌ 취소", "callback_data": f"plan:cancel:{pause_id}"},
743
948
  ]]}
744
949
  await send_message(client, chat_id, "\n".join(lines), reply_markup=keyboard)
745
950
 
@@ -749,8 +954,8 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
749
954
  parts = data.split(":", 2)
750
955
  if len(parts) != 3:
751
956
  return
752
- _, action, context_id = parts
753
- pending = _bot_pending_plans.get(context_id)
957
+ _, action, pause_id = parts
958
+ pending = _bot_pending_plans.get(pause_id)
754
959
 
755
960
  # A callback is bound to the chat that received the plan. Even two allowed
756
961
  # chats cannot approve or cancel each other's plan by replaying callback
@@ -758,9 +963,19 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
758
963
  if pending and int(pending.get("chat_id")) != int(chat_id):
759
964
  await send_message(client, chat_id, "❌ 다른 채팅의 작업은 처리할 수 없습니다.")
760
965
  return
761
- pending = _bot_pending_plans.pop(context_id, None)
966
+ pending = _bot_pending_plans.pop(pause_id, None)
762
967
 
763
- if action == "cancel" or not pending:
968
+ if action == "cancel":
969
+ if pending:
970
+ try:
971
+ resume_body = _resume_payload(pending, approved=False)
972
+ async with _server_client() as sc:
973
+ await sc.post(AGENT_RESUME_URL, json=resume_body, timeout=30.0)
974
+ except Exception as exc: # noqa: BLE001 — cancel is best-effort
975
+ logger.warning("telegram cancel resume failed: %s", safe_log_text(exc))
976
+ await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
977
+ return
978
+ if not pending:
764
979
  await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
765
980
  return
766
981
 
@@ -769,22 +984,41 @@ async def handle_plan_callback(client, chat_id, data: str) -> None:
769
984
 
770
985
  try:
771
986
  async with _server_client() as sc:
772
- res = await sc.post(AGENT_RESUME_URL, json={
773
- "context_id": context_id,
774
- "approved": True,
775
- "executing_model": pending.get("executing_model"),
776
- "reviewing_model": pending.get("reviewing_model"),
777
- }, timeout=300.0)
987
+ res = await sc.post(
988
+ AGENT_RESUME_URL,
989
+ json=_resume_payload(pending, approved=True),
990
+ timeout=300.0,
991
+ )
778
992
  data_r = res.json() if res.status_code == 200 else {}
779
993
  ans = data_r.get("response", f"❌ 서버 에러 ({res.status_code})")
780
994
  await send_message(client, chat_id, str(ans))
781
995
  if isinstance(data_r, dict):
996
+ await send_run_explanation(client, chat_id, data_r)
782
997
  await send_generated_files(client, chat_id, collect_generated_files(data_r))
783
998
  await send_preview_links(client, chat_id, collect_preview_urls(data_r))
784
999
  except Exception as e:
785
1000
  await send_message(client, chat_id, f"❌ 실행 중 오류: {e}")
786
1001
 
787
1002
 
1003
+ def _resume_payload(pending: dict, *, approved: bool) -> dict:
1004
+ """Build /agent/resume body: token path preferred, else legacy context_id."""
1005
+ body: dict = {
1006
+ "approved": approved,
1007
+ "executing_model": pending.get("executing_model"),
1008
+ "reviewing_model": pending.get("reviewing_model"),
1009
+ }
1010
+ token = pending.get("approval_token")
1011
+ run_id = pending.get("run_id")
1012
+ # Unified durable store (9.9.5): token-gated resume works for both the
1013
+ # modern awaiting_approval and the legacy human_in_loop pause.
1014
+ if token and run_id:
1015
+ body["run_id"] = run_id
1016
+ body["approval_token"] = token
1017
+ return body
1018
+ body["context_id"] = pending.get("context_id") or run_id
1019
+ return body
1020
+
1021
+
788
1022
  # ── AI request task ───────────────────────────────────────────────────────────
789
1023
 
790
1024
  async def process_ai_request(client, chat_id, user_text, image_data=None):
@@ -794,8 +1028,8 @@ async def process_ai_request(client, chat_id, user_text, image_data=None):
794
1028
  data = await ask_ai(client, user_text, image_data, agent_mode=not image_data)
795
1029
  logger.info("ask_ai 완료: chat_id=%s result_keys=%s", chat_id, list(data.keys()) if isinstance(data, dict) else type(data))
796
1030
 
797
- # Human-in-the-loop: show plan and wait for approval
798
- if isinstance(data, dict) and data.get("status") == "waiting_approval":
1031
+ # Approval pause (legacy waiting_approval or modern awaiting_approval)
1032
+ if isinstance(data, dict) and _is_approval_pause(data):
799
1033
  await send_plan_for_approval(client, chat_id, data)
800
1034
  return
801
1035
 
@@ -803,7 +1037,12 @@ async def process_ai_request(client, chat_id, user_text, image_data=None):
803
1037
  if not ans or not str(ans).strip():
804
1038
  ans = "⚠️ AI가 답변을 생성하지 못했습니다."
805
1039
  await send_message(client, chat_id, str(ans))
1040
+ if isinstance(data, dict):
1041
+ # Recall parity (v9.9.7): the same grounding verdict the web app
1042
+ # badges, never promoted when the server issued none.
1043
+ await send_grounding_badge(client, chat_id, data)
806
1044
  if not image_data and isinstance(data, dict):
1045
+ await send_run_explanation(client, chat_id, data)
807
1046
  await send_generated_files(client, chat_id, collect_generated_files(data))
808
1047
  await send_preview_links(client, chat_id, collect_preview_urls(data))
809
1048
  except Exception as e:
@@ -828,6 +1067,7 @@ HELP_TEXT = """\
828
1067
  /clear [n] — 기록 정리 (마지막 n건 유지)
829
1068
  /web — 웹 UI 링크
830
1069
  /mcp — MCP 도구 목록
1070
+ /review — 변경 제안 검토함 (승인/거절)
831
1071
  /help — 이 도움말
832
1072
 
833
1073
  /agent <작업> — 멀티 LLM 에이전트 (계획 확인 후 실행)
@@ -866,6 +1106,8 @@ async def handle_command(client, chat_id, command: str, args: str):
866
1106
  await send_web_link(client, chat_id)
867
1107
  elif cmd == "mcp":
868
1108
  await send_mcp_tools(client, chat_id)
1109
+ elif cmd in {"review", "proposals"}:
1110
+ await show_review_center(client, chat_id)
869
1111
  elif cmd in {"help", "h"}:
870
1112
  await send_message(client, chat_id, HELP_TEXT)
871
1113
  elif cmd == "agent":
@@ -887,7 +1129,7 @@ async def handle_command(client, chat_id, command: str, args: str):
887
1129
  await send_chat_action(client, chat_id, "typing")
888
1130
  data = await ask_ai(client, task_text, agent_mode=True,
889
1131
  executing_model=exec_model, reviewing_model=reviewing_model)
890
- if isinstance(data, dict) and data.get("status") == "waiting_approval":
1132
+ if isinstance(data, dict) and _is_approval_pause(data):
891
1133
  await send_plan_for_approval(client, chat_id, data)
892
1134
  else:
893
1135
  ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
@@ -927,6 +1169,8 @@ async def handle_callback_query(client, callback_query):
927
1169
  await send_web_link(client, chat_id)
928
1170
  elif data == "cmd:mcp":
929
1171
  await send_mcp_tools(client, chat_id)
1172
+ elif data == "cmd:review":
1173
+ await show_review_center(client, chat_id)
930
1174
  elif data == "cmd:menu":
931
1175
  await show_menu(client, chat_id)
932
1176
  elif data.startswith("model:unload:"):
@@ -935,6 +1179,9 @@ async def handle_callback_query(client, callback_query):
935
1179
  elif data.startswith("plan:"):
936
1180
  task = asyncio.create_task(handle_plan_callback(client, chat_id, data))
937
1181
  task.add_done_callback(_log_task_exception)
1182
+ elif data.startswith("proposal:"):
1183
+ task = asyncio.create_task(handle_proposal_callback(client, chat_id, data))
1184
+ task.add_done_callback(_log_task_exception)
938
1185
 
939
1186
  # ── Main loop ─────────────────────────────────────────────────────────────────
940
1187
 
@@ -4,6 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any, Optional
6
6
 
7
+ from latticeai.runtime.permission_mode_wiring import (
8
+ bind_dispatch_permission_mode,
9
+ resolve_active_permission_mode,
10
+ )
7
11
  from latticeai.services.router_context import InteractionRouterContext, ToolRouterContext
8
12
 
9
13
 
@@ -19,6 +23,9 @@ def build_chat_agent_runtime_from_context(
19
23
  hooks: Any,
20
24
  brain_memory: Any,
21
25
  ) -> Any:
26
+ # Ensure dispatch + agent share the same autonomy dial before the runtime
27
+ # is constructed (process-wide service; data_dir refined later at router mount).
28
+ bind_dispatch_permission_mode()
22
29
  return build_agent_runtime(
23
30
  model_router=model_router,
24
31
  execute_tool=execute_tool,
@@ -28,6 +35,7 @@ def build_chat_agent_runtime_from_context(
28
35
  audit=audit,
29
36
  hooks=hooks,
30
37
  brain_memory=brain_memory,
38
+ permission_mode=resolve_active_permission_mode,
31
39
  )
32
40
 
33
41
 
@@ -13,6 +13,7 @@ def build_context_runtime(
13
13
  gardener: Any,
14
14
  require_auth: bool,
15
15
  allowed_scopes_for_user: Callable[[Any], Any],
16
+ artifact_ledger: Any = None,
16
17
  ) -> Dict[str, Any]:
17
18
  """Construct search, brain memory, and context assembly services."""
18
19
 
@@ -41,15 +42,25 @@ def build_context_runtime(
41
42
  allowed = allowed_scopes_for_user(user_email)
42
43
  return gardener.get_relevant_context(q, allowed_workspaces=allowed, **kw)
43
44
 
45
+ # Re-search loop (v9.9.6): files this conversation just produced are a
46
+ # deterministic context section, so "그 파일에 다크모드 넣어줘" works before
47
+ # asynchronous indexing catches up. Optional — absent ledger, absent section.
48
+ if artifact_ledger is None:
49
+ from latticeai.core.artifact_ledger import ArtifactLedger
50
+
51
+ artifact_ledger = ArtifactLedger()
52
+
44
53
  context_assembler = ContextAssembler(
45
54
  memory_recall=memory_service.recall,
46
55
  hybrid_search=scoped_hybrid_search,
47
56
  notes_context=scoped_notes_context,
57
+ recent_artifacts=artifact_ledger.recent,
48
58
  )
49
59
 
50
60
  return {
51
61
  "SEARCH_SERVICE": search_service,
52
62
  "BRAIN_MEMORY": brain_memory,
53
63
  "CONTEXT_ASSEMBLER": context_assembler,
64
+ "ARTIFACT_LEDGER": artifact_ledger,
54
65
  "_scoped_hybrid_search": scoped_hybrid_search,
55
66
  }