ltcai 9.9.5 → 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 (132) hide show
  1. package/README.md +41 -36
  2. package/docs/CHANGELOG.md +193 -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 +38 -8
  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/retrieval_vector.py +10 -0
  19. package/lattice_brain/ingestion.py +9 -106
  20. package/lattice_brain/ingestion_jobs.py +133 -0
  21. package/lattice_brain/runtime/multi_agent.py +1 -1
  22. package/latticeai/__init__.py +1 -1
  23. package/latticeai/api/brain_intelligence.py +8 -0
  24. package/latticeai/api/chat.py +9 -0
  25. package/latticeai/api/chat_agent_http.py +119 -2
  26. package/latticeai/api/chat_contracts.py +9 -0
  27. package/latticeai/api/chat_documents.py +16 -0
  28. package/latticeai/api/evidence_actions.py +48 -0
  29. package/latticeai/api/permission_mode.py +61 -0
  30. package/latticeai/api/project_sessions.py +112 -0
  31. package/latticeai/api/voice_capture.py +89 -0
  32. package/latticeai/app_factory.py +53 -0
  33. package/latticeai/core/agent.py +381 -43
  34. package/latticeai/core/agent_permission.py +160 -0
  35. package/latticeai/core/agent_profiles.py +110 -0
  36. package/latticeai/core/artifact_ledger.py +109 -0
  37. package/latticeai/core/context_builder.py +101 -2
  38. package/latticeai/core/legacy_compatibility.py +1 -1
  39. package/latticeai/core/marketplace.py +1 -1
  40. package/latticeai/core/permission_mode.py +296 -0
  41. package/latticeai/core/project_sessions.py +337 -0
  42. package/latticeai/core/run_explain.py +421 -0
  43. package/latticeai/core/run_store.py +5 -0
  44. package/latticeai/core/workspace_os.py +15 -80
  45. package/latticeai/core/workspace_review_items.py +107 -0
  46. package/latticeai/integrations/telegram_bot.py +188 -0
  47. package/latticeai/runtime/chat_wiring.py +8 -0
  48. package/latticeai/runtime/context_runtime.py +11 -0
  49. package/latticeai/runtime/permission_mode_wiring.py +116 -0
  50. package/latticeai/runtime/router_registration.py +9 -0
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/brain_intelligence.py +105 -0
  54. package/latticeai/services/evidence_actions.py +254 -0
  55. package/latticeai/services/funnel_metrics.py +111 -16
  56. package/latticeai/services/local_knowledge.py +26 -0
  57. package/latticeai/services/memory_service.py +12 -0
  58. package/latticeai/services/permission_mode_service.py +170 -0
  59. package/latticeai/services/product_readiness.py +1 -1
  60. package/latticeai/services/tool_dispatch.py +73 -4
  61. package/latticeai/services/voice_capture.py +189 -0
  62. package/package.json +5 -3
  63. package/scripts/check_bundle_budget.mjs +11 -0
  64. package/scripts/check_current_release_docs.mjs +1 -1
  65. package/scripts/check_i18n_namespace_coverage.mjs +148 -0
  66. package/skills/meeting_notes/SKILL.md +82 -0
  67. package/skills/meeting_notes/examples.md +41 -0
  68. package/skills/meeting_notes/risk.json +9 -0
  69. package/skills/meeting_notes/schema.json +55 -0
  70. package/skills/weekly_review/SKILL.md +75 -0
  71. package/skills/weekly_review/examples.md +39 -0
  72. package/skills/weekly_review/risk.json +9 -0
  73. package/skills/weekly_review/schema.json +55 -0
  74. package/src-tauri/Cargo.lock +1 -1
  75. package/src-tauri/Cargo.toml +1 -1
  76. package/src-tauri/tauri.conf.json +1 -1
  77. package/static/app/asset-manifest.json +37 -29
  78. package/static/app/assets/Act-BFbpl2_Y.js +1 -0
  79. package/static/app/assets/AdminConsole-DaZPha9Y.js +1 -0
  80. package/static/app/assets/Brain-DGrV0heZ.js +321 -0
  81. package/static/app/assets/BrainHome-Re6Zxh_W.js +2 -0
  82. package/static/app/assets/BrainSignals-BQ11jDpJ.js +1 -0
  83. package/static/app/assets/Capture-DRaf1zLH.js +1 -0
  84. package/static/app/assets/CommandPalette-DAKPm4uJ.js +1 -0
  85. package/static/app/assets/LanguageSwitcher-BgTwHoWv.js +1 -0
  86. package/static/app/assets/Library-BVtrSt12.js +1 -0
  87. package/static/app/assets/LivingBrain-7glOmK0R.js +1 -0
  88. package/static/app/assets/ProductFlow-cB3EFinZ.js +1 -0
  89. package/static/app/assets/ReviewCard-CShhHS-w.js +3 -0
  90. package/static/app/assets/System-B4nQDy8a.js +1 -0
  91. package/static/app/assets/{bot-VmP0kkeA.js → bot-DtKkLfXj.js} +1 -1
  92. package/static/app/assets/brain-wVV63_1z.js +1 -0
  93. package/static/app/assets/button-BTxZDMFJ.js +1 -0
  94. package/static/app/assets/{circle-pause-T9POo4qy.js → circle-pause--qxF9-EO.js} +1 -1
  95. package/static/app/assets/{circle-play-Bz5iTD0p.js → circle-play-89x3S6jm.js} +1 -1
  96. package/static/app/assets/{cpu-CJhjRtNq.js → cpu-DTUI63vo.js} +1 -1
  97. package/static/app/assets/download-EJS5_O0f.js +1 -0
  98. package/static/app/assets/{folder-open-B2K_22VI.js → folder-open-BL9zYTnc.js} +1 -1
  99. package/static/app/assets/{hard-drive-CjJtYqHf.js → hard-drive-BFnwi1Lu.js} +1 -1
  100. package/static/app/assets/index-BFtDYI6l.js +10 -0
  101. package/static/app/assets/index-BY9bpxyx.css +2 -0
  102. package/static/app/assets/input-DEz5UjBz.js +1 -0
  103. package/static/app/assets/{network-Ck1nqc-v.js → network-zKa3jAwl.js} +1 -1
  104. package/static/app/assets/primitives-jNrZm2O9.js +1 -0
  105. package/static/app/assets/search-ku6-_6wY.js +1 -0
  106. package/static/app/assets/shield-alert-CpHeCsaF.js +1 -0
  107. package/static/app/assets/textarea-DpcQkn75.js +1 -0
  108. package/static/app/assets/{useFocusTrap-CsnIhsDJ.js → useFocusTrap-58TNCONM.js} +1 -1
  109. package/static/app/assets/{navigation-BPQqEQwr.js → useQuery-CcVlXlBd.js} +1 -1
  110. package/static/app/assets/users-C0cFMj6o.js +1 -0
  111. package/static/app/assets/utils-DYf7pqrR.js +7 -0
  112. package/static/app/assets/workspace-CjthHsLN.js +1 -0
  113. package/static/app/index.html +4 -4
  114. package/static/sw.js +1 -1
  115. package/static/app/assets/Act-DmxfbqBp.js +0 -1
  116. package/static/app/assets/Brain-DK_zKCda.js +0 -321
  117. package/static/app/assets/BrainHome-CCE_0hni.js +0 -2
  118. package/static/app/assets/BrainSignals-r_J68_zh.js +0 -1
  119. package/static/app/assets/Capture-CvxwzpJZ.js +0 -1
  120. package/static/app/assets/CommandPalette-CVLiFIQC.js +0 -1
  121. package/static/app/assets/Library-4As9VUvy.js +0 -1
  122. package/static/app/assets/LivingBrain-BVWk58MW.js +0 -1
  123. package/static/app/assets/ProductFlow-B-Tz0Um4.js +0 -1
  124. package/static/app/assets/ReviewCard-Va3fPQuj.js +0 -3
  125. package/static/app/assets/System-DSR2zKWd.js +0 -1
  126. package/static/app/assets/index-7FAfYm4v.css +0 -2
  127. package/static/app/assets/index-CEu0Wqjl.js +0 -10
  128. package/static/app/assets/input-CSi6OBJ9.js +0 -1
  129. package/static/app/assets/primitives-B2f9N7Dh.js +0 -1
  130. package/static/app/assets/shield-alert-yA_Y8lOB.js +0 -1
  131. package/static/app/assets/textarea-C4wM0D-Z.js +0 -1
  132. package/static/app/assets/utils-BqoznzGP.js +0 -7
@@ -0,0 +1,296 @@
1
+ """Permission modes for agent/tool autonomy (v9.9.8).
2
+
3
+ Frontier agents expose a mode dial (Claude Code: default → acceptEdits →
4
+ auto → bypassPermissions; Cursor: allowlist → auto-review → run everything;
5
+ Codex: approval × sandbox). LatticeAI maps the same idea onto the existing
6
+ ToolRegistry + Change Governor without throwing those gates away.
7
+
8
+ Modes
9
+ -----
10
+ * ``strict`` — current fail-closed defaults (every non-auto tool needs
11
+ approval; mutations become review proposals).
12
+ * ``trusted`` — workspace autonomy: knowledge reads, workspace writes
13
+ (additive + mutation), and computer *observation* auto-run. Exec,
14
+ destructive, host-control, and system-sandbox writes still gate.
15
+ * ``bypass`` — YOLO within the agent workspace. Hard circuit breakers
16
+ still fire (blocked path prefixes, destructive root/home removals,
17
+ system-sandbox writes that escape the workspace).
18
+
19
+ Circuit breakers are mode-invariant: a mode never overrides a hard deny.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from enum import Enum
25
+ from typing import Any, Dict, Mapping, Optional, Set
26
+
27
+
28
+ class PermissionMode(str, Enum):
29
+ STRICT = "strict"
30
+ TRUSTED = "trusted"
31
+ BYPASS = "bypass"
32
+
33
+
34
+ DEFAULT_MODE = PermissionMode.STRICT
35
+
36
+ # Computer-use split: observation is low-friction in trusted; control stays gated.
37
+ COMPUTER_OBSERVATION_TOOLS: Set[str] = frozenset({
38
+ "computer_screenshot",
39
+ "computer_status",
40
+ "computer_use_status",
41
+ "chrome_status",
42
+ "vision_analyze",
43
+ })
44
+
45
+ COMPUTER_CONTROL_TOOLS: Set[str] = frozenset({
46
+ "computer_click",
47
+ "computer_type",
48
+ "computer_key",
49
+ "computer_scroll",
50
+ "computer_drag",
51
+ "computer_move",
52
+ "computer_open_app",
53
+ "computer_open_url",
54
+ })
55
+
56
+ KNOWLEDGE_READ_TOOLS: Set[str] = frozenset({
57
+ "knowledge_search",
58
+ "knowledge_tree",
59
+ "obsidian_search",
60
+ "obsidian_tree",
61
+ "knowledge_graph_search",
62
+ "knowledge_graph_graph",
63
+ "knowledge_graph_context",
64
+ })
65
+
66
+ WORKSPACE_WRITE_TOOLS: Set[str] = frozenset({
67
+ "write_file",
68
+ "edit_file",
69
+ "create_docx",
70
+ "create_xlsx",
71
+ "create_pptx",
72
+ "create_pdf",
73
+ "create_web_project",
74
+ "knowledge_save",
75
+ "obsidian_save",
76
+ "knowledge_graph_ingest",
77
+ "todo_write",
78
+ })
79
+
80
+ # Always blocked regardless of mode (Claude-style circuit breakers).
81
+ HARD_BLOCK_SANDBOXES: Set[str] = frozenset({"system"})
82
+
83
+
84
+ def normalize_mode(value: Any) -> PermissionMode:
85
+ """Parse user/API/env input into a PermissionMode; unknown → strict."""
86
+ if isinstance(value, PermissionMode):
87
+ return value
88
+ text = str(value or "").strip().lower()
89
+ aliases = {
90
+ "strict": PermissionMode.STRICT,
91
+ "default": PermissionMode.STRICT,
92
+ "manual": PermissionMode.STRICT,
93
+ "trusted": PermissionMode.TRUSTED,
94
+ "acceptedits": PermissionMode.TRUSTED,
95
+ "accept_edits": PermissionMode.TRUSTED,
96
+ "workspace": PermissionMode.TRUSTED,
97
+ "bypass": PermissionMode.BYPASS,
98
+ "bypasspermissions": PermissionMode.BYPASS,
99
+ "bypass_permissions": PermissionMode.BYPASS,
100
+ "yolo": PermissionMode.BYPASS,
101
+ "dangerously-skip-permissions": PermissionMode.BYPASS,
102
+ }
103
+ return aliases.get(text, DEFAULT_MODE)
104
+
105
+
106
+ def mode_catalog() -> list[Dict[str, Any]]:
107
+ """UI/API catalog for the mode selector."""
108
+ return [
109
+ {
110
+ "id": PermissionMode.STRICT.value,
111
+ "label": "Strict",
112
+ "label_ko": "엄격",
113
+ "summary": "Reads auto; writes and exec need approval or review proposals.",
114
+ "summary_ko": "읽기는 자동, 쓰기·실행은 승인 또는 변경 제안.",
115
+ "risk": "low",
116
+ "requires_ack": False,
117
+ },
118
+ {
119
+ "id": PermissionMode.TRUSTED.value,
120
+ "label": "Trusted",
121
+ "label_ko": "신뢰",
122
+ "summary": "Workspace writes and knowledge reads auto-run; exec/desktop control still gated.",
123
+ "summary_ko": "워크스페이스 쓰기·지식 읽기 자동. 실행·데스크톱 제어는 승인 필요.",
124
+ "risk": "medium",
125
+ "requires_ack": False,
126
+ },
127
+ {
128
+ "id": PermissionMode.BYPASS.value,
129
+ "label": "Bypass",
130
+ "label_ko": "바이패스",
131
+ "summary": "YOLO inside the agent workspace. Hard circuit breakers still apply.",
132
+ "summary_ko": "에이전트 워크스페이스 안에서 전부 자동. 하드 차단만 남음.",
133
+ "risk": "high",
134
+ "requires_ack": True,
135
+ "warning": (
136
+ "Bypass skips routine approval prompts. Destructive system paths, "
137
+ "root/home wipes, and blocked prefixes remain denied."
138
+ ),
139
+ "warning_ko": (
140
+ "바이패스는 일상 승인 프롬프트를 건너뜁니다. 시스템 경로 파괴, "
141
+ "루트/홈 삭제, 차단 접두사는 계속 거부됩니다."
142
+ ),
143
+ },
144
+ ]
145
+
146
+
147
+ def is_circuit_breaker(
148
+ tool_name: str,
149
+ policy: Mapping[str, Any],
150
+ args: Optional[Mapping[str, Any]] = None,
151
+ ) -> Optional[str]:
152
+ """Return a reason string when the call must be denied in every mode."""
153
+ risk = str(policy.get("risk") or "")
154
+ destructive = bool(policy.get("destructive"))
155
+
156
+ if destructive or risk == "destructive":
157
+ return "destructive action is always blocked"
158
+
159
+ # NOTE: system-sandbox writes are *mode-sensitive*, not circuit breakers —
160
+ # ``effective_auto_approve`` keeps them gated under every mode and the
161
+ # blocked-path-prefix guard upstream denies escapes outright. Deciding them
162
+ # here would make ``bypass`` unable to drive desktop control at all.
163
+
164
+ path = str((args or {}).get("path") or (args or {}).get("filename") or "")
165
+ if path:
166
+ normalized = path.replace("\\", "/")
167
+ # Root / home mass-delete style paths — Claude circuit breaker analogue.
168
+ if normalized in {"/", "~", "/home", "/Users"} or normalized.rstrip("/") in {"/", "~"}:
169
+ return f"circuit breaker: refusing path {path!r}"
170
+
171
+ command = str((args or {}).get("command") or (args or {}).get("cmd") or "")
172
+ lowered = command.lower()
173
+ if any(token in lowered for token in ("rm -rf /", "rm -rf ~", "rm -rf /*", "rm -rf $home")):
174
+ return "circuit breaker: refusing destructive shell command"
175
+
176
+ return None
177
+
178
+
179
+ def effective_auto_approve(
180
+ mode: PermissionMode | str,
181
+ tool_name: str,
182
+ policy: Mapping[str, Any],
183
+ *,
184
+ change_class: Optional[str] = None,
185
+ args: Optional[Mapping[str, Any]] = None,
186
+ ) -> bool:
187
+ """Whether this tool call may run without an extra human approval prompt.
188
+
189
+ Does **not** override circuit breakers — callers must check
190
+ :func:`is_circuit_breaker` first and deny when it returns a reason.
191
+ """
192
+ mode = normalize_mode(mode)
193
+ if policy.get("auto_approve"):
194
+ return True
195
+
196
+ risk = str(policy.get("risk") or "")
197
+ sandbox = str(policy.get("sandbox") or "workspace")
198
+
199
+ if mode == PermissionMode.STRICT:
200
+ return False
201
+
202
+ if mode == PermissionMode.TRUSTED:
203
+ if tool_name in KNOWLEDGE_READ_TOOLS:
204
+ return True
205
+ if tool_name in COMPUTER_OBSERVATION_TOOLS:
206
+ return True
207
+ if tool_name in WORKSPACE_WRITE_TOOLS and sandbox == "workspace":
208
+ # Additive and mutation both auto under trusted; destructive still
209
+ # blocked by circuit breaker / risk check above.
210
+ if risk in {"write", "write_scoped", "read"} or change_class in {
211
+ "additive", "mutation", "read", None,
212
+ }:
213
+ if risk != "destructive" and not policy.get("destructive"):
214
+ return True
215
+ # Explicit reads that were gated only for consent (local_list etc.) stay gated.
216
+ return False
217
+
218
+ # bypass
219
+ if risk == "destructive" or policy.get("destructive"):
220
+ return False
221
+ if sandbox == "system" and tool_name not in COMPUTER_OBSERVATION_TOOLS | COMPUTER_CONTROL_TOOLS:
222
+ # Non-desktop system tools stay gated even in bypass.
223
+ if risk in {"write", "exec"}:
224
+ return False
225
+ return True
226
+
227
+
228
+ def should_stage_proposal(
229
+ mode: PermissionMode | str,
230
+ *,
231
+ proposal_required: bool,
232
+ ) -> bool:
233
+ """Whether mutation/destructive file changes should become Review proposals.
234
+
235
+ * strict — yes (proposal-first, current behaviour)
236
+ * trusted — no for mutations (auto-apply + audit); destructive still blocked
237
+ upstream so this only affects mutation proposals
238
+ * bypass — no
239
+ """
240
+ if not proposal_required:
241
+ return False
242
+ mode = normalize_mode(mode)
243
+ return mode == PermissionMode.STRICT
244
+
245
+
246
+ def plan_requires_approval(
247
+ mode: PermissionMode | str,
248
+ *,
249
+ non_auto_steps: list,
250
+ plan_flag: bool = False,
251
+ ) -> bool:
252
+ """Plan-level gate used by SingleAgentRuntime.approval_requirements."""
253
+ mode = normalize_mode(mode)
254
+ if mode == PermissionMode.BYPASS:
255
+ return False
256
+ if mode == PermissionMode.TRUSTED:
257
+ # Only steps that remain non-auto under trusted force a plan pause.
258
+ return bool(non_auto_steps) or bool(plan_flag)
259
+ return bool(non_auto_steps) or bool(plan_flag)
260
+
261
+
262
+ def mode_contract(mode: PermissionMode | str) -> Dict[str, Any]:
263
+ """Serializable contract for API / agent responses."""
264
+ mode = normalize_mode(mode)
265
+ entry = next((m for m in mode_catalog() if m["id"] == mode.value), mode_catalog()[0])
266
+ return {
267
+ "mode": mode.value,
268
+ "label": entry["label"],
269
+ "label_ko": entry["label_ko"],
270
+ "risk": entry["risk"],
271
+ "requires_ack": entry["requires_ack"],
272
+ "proposal_first": mode == PermissionMode.STRICT,
273
+ "workspace_writes_auto": mode in {PermissionMode.TRUSTED, PermissionMode.BYPASS},
274
+ "knowledge_reads_auto": mode in {PermissionMode.TRUSTED, PermissionMode.BYPASS},
275
+ "exec_auto": mode == PermissionMode.BYPASS,
276
+ "computer_observation_auto": mode in {PermissionMode.TRUSTED, PermissionMode.BYPASS},
277
+ "computer_control_auto": mode == PermissionMode.BYPASS,
278
+ "circuit_breakers": True,
279
+ }
280
+
281
+
282
+ __all__ = [
283
+ "PermissionMode",
284
+ "DEFAULT_MODE",
285
+ "COMPUTER_OBSERVATION_TOOLS",
286
+ "COMPUTER_CONTROL_TOOLS",
287
+ "KNOWLEDGE_READ_TOOLS",
288
+ "WORKSPACE_WRITE_TOOLS",
289
+ "normalize_mode",
290
+ "mode_catalog",
291
+ "is_circuit_breaker",
292
+ "effective_auto_approve",
293
+ "should_stage_proposal",
294
+ "plan_requires_approval",
295
+ "mode_contract",
296
+ ]
@@ -0,0 +1,337 @@
1
+ """Persistent project sessions — the multi-turn project loop (v9.9.6).
2
+
3
+ A single agent run is well modelled: PLAN → EXECUTE → VERIFY, with a durable
4
+ approval pause and a transcript. What was missing is the loop *above* it —
5
+ "만들고 → 고치고 → 검증하고 → 다시 고치는" work that spans several runs and
6
+ several days. Each run started from a blank workspace picture, so the second
7
+ turn could not see what the first produced, what still failed verification, or
8
+ what was left to do.
9
+
10
+ A project session is that missing state:
11
+
12
+ * ``files`` — every artifact the project's runs actually produced;
13
+ * ``todos`` — what is still open (explicit, user- or agent-authored);
14
+ * ``last_verification`` — the most recent honest outcome (never upgraded);
15
+ * ``runs`` — the run history that got there.
16
+
17
+ Storage mirrors :mod:`latticeai.core.run_store`: one JSON file per session
18
+ under the data dir, atomic replace on write, no new dependencies. Reads are
19
+ fail-open (a corrupt file is skipped, not fatal); writes never break a run —
20
+ a project session is context, not a gate.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import re
27
+ import secrets
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ from latticeai.core.io_utils import atomic_write_json
32
+ from latticeai.core.timeutil import now_iso
33
+
34
+ LOGGER = logging.getLogger(__name__)
35
+
36
+ __all__ = ["ProjectSessionStore", "PROJECT_STATUSES"]
37
+
38
+ # Terminal-ish project states. "active" is the default; "archived" hides a
39
+ # project from the default listing without deleting its history.
40
+ PROJECT_STATUSES = ("active", "archived")
41
+
42
+ _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
43
+ _MAX_FILES = 200
44
+ _MAX_TODOS = 100
45
+ _MAX_RUNS = 100
46
+
47
+
48
+ def _clean(value: Any, limit: int) -> str:
49
+ return str(value or "").strip()[:limit]
50
+
51
+
52
+ class ProjectSessionStore:
53
+ """JSON-file store for long-running project sessions."""
54
+
55
+ def __init__(self, root: Path) -> None:
56
+ self._root = Path(root)
57
+
58
+ # ── paths ────────────────────────────────────────────────────────────
59
+ def _path(self, session_id: str) -> Optional[Path]:
60
+ if not _SESSION_ID_RE.match(str(session_id or "")):
61
+ return None
62
+ return self._root / f"{session_id}.json"
63
+
64
+ def _ensure_root(self) -> None:
65
+ self._root.mkdir(parents=True, exist_ok=True)
66
+
67
+ # ── read ─────────────────────────────────────────────────────────────
68
+ def _load(self, session_id: str) -> Optional[Dict[str, Any]]:
69
+ path = self._path(session_id)
70
+ if path is None or not path.exists():
71
+ return None
72
+ try:
73
+ import json
74
+
75
+ with path.open(encoding="utf-8") as handle:
76
+ data = json.load(handle)
77
+ return data if isinstance(data, dict) else None
78
+ except Exception as exc: # noqa: BLE001 — a bad file is skipped, not fatal
79
+ LOGGER.warning("project session load failed for %s: %s", session_id, exc)
80
+ return None
81
+
82
+ def get(
83
+ self,
84
+ session_id: str,
85
+ *,
86
+ user_email: Optional[str] = None,
87
+ workspace_id: Optional[str] = None,
88
+ ) -> Optional[Dict[str, Any]]:
89
+ """One session, scoped. Returns None when it does not exist or is
90
+ outside the caller's scope — never a partial record."""
91
+ record = self._load(session_id)
92
+ if record is None:
93
+ return None
94
+ if user_email is not None and record.get("user_email") != user_email:
95
+ return None
96
+ if workspace_id is not None and record.get("workspace_id") != workspace_id:
97
+ return None
98
+ return record
99
+
100
+ def list(
101
+ self,
102
+ *,
103
+ user_email: Optional[str] = None,
104
+ workspace_id: Optional[str] = None,
105
+ status: str = "active",
106
+ ) -> Dict[str, Any]:
107
+ """Sessions for a caller, newest first. ``status="all"`` includes archived."""
108
+ if not self._root.exists():
109
+ return {"projects": [], "count": 0}
110
+ sessions: List[Dict[str, Any]] = []
111
+ for path in self._root.glob("*.json"):
112
+ record = self._load(path.stem)
113
+ if record is None:
114
+ continue
115
+ if user_email is not None and record.get("user_email") != user_email:
116
+ continue
117
+ if workspace_id is not None and record.get("workspace_id") != workspace_id:
118
+ continue
119
+ if status != "all" and record.get("status") != status:
120
+ continue
121
+ sessions.append(record)
122
+ sessions.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
123
+ return {"projects": sessions, "count": len(sessions)}
124
+
125
+ # ── write ────────────────────────────────────────────────────────────
126
+ def _save(self, record: Dict[str, Any]) -> Dict[str, Any]:
127
+ record["updated_at"] = now_iso()
128
+ path = self._path(str(record.get("id") or ""))
129
+ if path is None:
130
+ raise ValueError("invalid project session id")
131
+ try:
132
+ self._ensure_root()
133
+ atomic_write_json(path, record)
134
+ except Exception as exc: # noqa: BLE001 — persistence never breaks a run
135
+ LOGGER.warning("project session save failed for %s: %s", record.get("id"), exc)
136
+ return record
137
+
138
+ def create(
139
+ self,
140
+ *,
141
+ title: str,
142
+ goal: str = "",
143
+ user_email: Optional[str] = None,
144
+ workspace_id: Optional[str] = None,
145
+ ) -> Dict[str, Any]:
146
+ session_id = secrets.token_urlsafe(12)
147
+ record = {
148
+ "id": session_id,
149
+ "title": _clean(title, 200) or "프로젝트",
150
+ "goal": _clean(goal, 2000),
151
+ "status": "active",
152
+ "user_email": user_email,
153
+ "workspace_id": workspace_id,
154
+ "created_at": now_iso(),
155
+ "updated_at": now_iso(),
156
+ "files": [],
157
+ "todos": [],
158
+ "runs": [],
159
+ "last_verification": None,
160
+ }
161
+ return self._save(record)
162
+
163
+ def update(
164
+ self,
165
+ session_id: str,
166
+ *,
167
+ title: Optional[str] = None,
168
+ goal: Optional[str] = None,
169
+ status: Optional[str] = None,
170
+ user_email: Optional[str] = None,
171
+ workspace_id: Optional[str] = None,
172
+ ) -> Optional[Dict[str, Any]]:
173
+ record = self.get(session_id, user_email=user_email, workspace_id=workspace_id)
174
+ if record is None:
175
+ return None
176
+ if title is not None:
177
+ record["title"] = _clean(title, 200) or record["title"]
178
+ if goal is not None:
179
+ record["goal"] = _clean(goal, 2000)
180
+ if status is not None and status in PROJECT_STATUSES:
181
+ record["status"] = status
182
+ return self._save(record)
183
+
184
+ def delete(
185
+ self,
186
+ session_id: str,
187
+ *,
188
+ user_email: Optional[str] = None,
189
+ workspace_id: Optional[str] = None,
190
+ ) -> bool:
191
+ record = self.get(session_id, user_email=user_email, workspace_id=workspace_id)
192
+ if record is None:
193
+ return False
194
+ path = self._path(session_id)
195
+ try:
196
+ if path is not None and path.exists():
197
+ path.unlink()
198
+ return True
199
+ except OSError as exc:
200
+ LOGGER.warning("project session delete failed for %s: %s", session_id, exc)
201
+ return False
202
+
203
+ def set_todos(
204
+ self,
205
+ session_id: str,
206
+ todos: List[Any],
207
+ *,
208
+ user_email: Optional[str] = None,
209
+ workspace_id: Optional[str] = None,
210
+ ) -> Optional[Dict[str, Any]]:
211
+ """Replace the open-work list. Accepts strings or ``{text, done}`` dicts."""
212
+ record = self.get(session_id, user_email=user_email, workspace_id=workspace_id)
213
+ if record is None:
214
+ return None
215
+ normalized: List[Dict[str, Any]] = []
216
+ for raw in list(todos or [])[:_MAX_TODOS]:
217
+ if isinstance(raw, dict):
218
+ text = _clean(raw.get("text"), 300)
219
+ done = bool(raw.get("done"))
220
+ else:
221
+ text = _clean(raw, 300)
222
+ done = False
223
+ if text:
224
+ normalized.append({"text": text, "done": done})
225
+ record["todos"] = normalized
226
+ return self._save(record)
227
+
228
+ def record_run(
229
+ self,
230
+ session_id: str,
231
+ *,
232
+ run_id: str = "",
233
+ goal: str = "",
234
+ status: str = "",
235
+ final_state: str = "",
236
+ files: Optional[List[Any]] = None,
237
+ explanation: Optional[Dict[str, Any]] = None,
238
+ user_email: Optional[str] = None,
239
+ workspace_id: Optional[str] = None,
240
+ ) -> Optional[Dict[str, Any]]:
241
+ """Fold one finished agent run into the project's state.
242
+
243
+ The verification result is recorded exactly as the run reported it —
244
+ a NEEDS_REVIEW run never becomes a project's "done".
245
+ """
246
+ record = self.get(session_id, user_email=user_email, workspace_id=workspace_id)
247
+ if record is None:
248
+ return None
249
+ paths: List[str] = []
250
+ for entry in list(files or []):
251
+ path = entry if isinstance(entry, str) else (entry or {}).get("path")
252
+ path = _clean(path, 400)
253
+ if path and path not in paths:
254
+ paths.append(path)
255
+ known = list(record.get("files") or [])
256
+ for path in paths:
257
+ if path not in known:
258
+ known.append(path)
259
+ record["files"] = known[-_MAX_FILES:]
260
+ headline = ""
261
+ next_step = ""
262
+ if isinstance(explanation, dict):
263
+ headline = _clean((explanation.get("headline") or {}).get("ko"), 400)
264
+ next_step = _clean((explanation.get("next_step") or {}).get("ko"), 400)
265
+ entry = {
266
+ "run_id": _clean(run_id, 120),
267
+ "goal": _clean(goal, 400),
268
+ "status": _clean(status, 40),
269
+ "final_state": _clean(final_state, 40),
270
+ "files": paths,
271
+ "at": now_iso(),
272
+ **({"headline": headline} if headline else {}),
273
+ **({"next_step": next_step} if next_step else {}),
274
+ }
275
+ runs = list(record.get("runs") or [])
276
+ runs.append(entry)
277
+ record["runs"] = runs[-_MAX_RUNS:]
278
+ record["last_verification"] = {
279
+ "final_state": entry["final_state"],
280
+ "ok": bool(isinstance(explanation, dict) and explanation.get("ok")),
281
+ "at": entry["at"],
282
+ **({"headline": headline} if headline else {}),
283
+ # Failure-learning loop (review 루프 §3): what the *last* attempt
284
+ # says to do differently travels into the next run's plan context
285
+ # instead of dying with the run.
286
+ **({"next_step": next_step} if next_step else {}),
287
+ }
288
+ return self._save(record)
289
+
290
+ # ── prompt context ───────────────────────────────────────────────────
291
+ def summary(
292
+ self,
293
+ session_id: str,
294
+ *,
295
+ user_email: Optional[str] = None,
296
+ workspace_id: Optional[str] = None,
297
+ max_files: int = 15,
298
+ max_todos: int = 10,
299
+ ) -> str:
300
+ """Prompt block describing where this project stands.
301
+
302
+ Injected into the planner/executor context so a later run sees the
303
+ files that already exist, what is still open, and whether the last
304
+ attempt actually passed. Empty string when there is nothing to say —
305
+ an empty project must not pad the prompt.
306
+ """
307
+ record = self.get(session_id, user_email=user_email, workspace_id=workspace_id)
308
+ if record is None:
309
+ return ""
310
+ lines: List[str] = []
311
+ title = record.get("title") or ""
312
+ goal = record.get("goal") or ""
313
+ lines.append(f"Project: {title}" + (f" — {goal}" if goal else ""))
314
+ files = list(record.get("files") or [])[-max_files:]
315
+ if files:
316
+ lines.append("Files this project has already produced (they exist now):")
317
+ lines.extend(f"- {path}" for path in files)
318
+ open_todos = [
319
+ todo for todo in (record.get("todos") or []) if not todo.get("done")
320
+ ][:max_todos]
321
+ if open_todos:
322
+ lines.append("Still open:")
323
+ lines.extend(f"- {todo['text']}" for todo in open_todos)
324
+ verification = record.get("last_verification")
325
+ if isinstance(verification, dict) and verification.get("final_state"):
326
+ verdict = verification["final_state"]
327
+ headline = verification.get("headline") or ""
328
+ lines.append(
329
+ f"Last verification: {verdict}" + (f" — {headline}" if headline else "")
330
+ )
331
+ next_step = verification.get("next_step") or ""
332
+ if next_step and not verification.get("ok"):
333
+ # The previous attempt's own diagnosis, so this plan does not
334
+ # repeat the same failure.
335
+ lines.append(f"What the last attempt said to do differently: {next_step}")
336
+ # Only the header line means nothing worth injecting.
337
+ return "\n".join(lines) if len(lines) > 1 else ""