ltcai 8.7.0 → 8.9.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 (103) hide show
  1. package/README.md +38 -26
  2. package/auto_setup.py +73 -8
  3. package/docs/CHANGELOG.md +63 -0
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +4 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +33 -14
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/TRUST_MODEL.md +5 -1
  11. package/docs/WHY_LATTICE.md +4 -3
  12. package/docs/architecture.md +4 -0
  13. package/docs/kg-schema.md +1 -1
  14. package/kg_schema.py +1 -1
  15. package/knowledge_graph.py +2 -2
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/conversations.py +156 -21
  18. package/lattice_brain/core.py +1 -1
  19. package/lattice_brain/graph/_kg_common.py +12 -34
  20. package/lattice_brain/graph/json_utils.py +25 -0
  21. package/lattice_brain/graph/retrieval.py +66 -27
  22. package/lattice_brain/graph/runtime.py +16 -0
  23. package/lattice_brain/ingestion.py +35 -20
  24. package/lattice_brain/runtime/agent_runtime.py +37 -3
  25. package/lattice_brain/runtime/multi_agent.py +1 -1
  26. package/latticeai/__init__.py +1 -1
  27. package/latticeai/api/chat.py +31 -14
  28. package/latticeai/api/mcp.py +3 -2
  29. package/latticeai/api/models.py +4 -1
  30. package/latticeai/api/permissions.py +69 -30
  31. package/latticeai/api/setup.py +17 -2
  32. package/latticeai/api/tools.py +104 -62
  33. package/latticeai/app_factory.py +93 -10
  34. package/latticeai/core/agent.py +25 -7
  35. package/latticeai/core/legacy_compatibility.py +36 -25
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/sessions.py +11 -3
  38. package/latticeai/core/tool_registry.py +15 -4
  39. package/latticeai/core/workspace_os.py +1 -1
  40. package/latticeai/runtime/bootstrap.py +1 -1
  41. package/latticeai/runtime/persistence_runtime.py +1 -1
  42. package/latticeai/runtime/platform_services_runtime.py +1 -1
  43. package/latticeai/services/app_context.py +1 -0
  44. package/latticeai/services/architecture_readiness.py +2 -2
  45. package/latticeai/services/memory_service.py +38 -7
  46. package/latticeai/services/model_engines.py +79 -12
  47. package/latticeai/services/model_runtime.py +24 -4
  48. package/latticeai/services/process_audit.py +208 -0
  49. package/latticeai/services/product_readiness.py +11 -11
  50. package/latticeai/services/search_service.py +106 -30
  51. package/latticeai/services/tool_dispatch.py +66 -0
  52. package/latticeai/services/workspace_service.py +15 -0
  53. package/package.json +1 -1
  54. package/scripts/check_i18n_literals.mjs +20 -8
  55. package/scripts/i18n_literal_allowlist.json +34 -0
  56. package/scripts/lint_frontend.mjs +6 -2
  57. package/setup_wizard.py +185 -19
  58. package/src-tauri/Cargo.lock +1 -1
  59. package/src-tauri/Cargo.toml +1 -1
  60. package/src-tauri/tauri.conf.json +1 -1
  61. package/static/app/asset-manifest.json +11 -11
  62. package/static/app/assets/{Act-_U7mhXir.js → Act-fZokUnC0.js} +1 -1
  63. package/static/app/assets/{Brain-BxyTHZ21.js → Brain-DtyuWubr.js} +2 -2
  64. package/static/app/assets/{Capture-DyDKWNh9.js → Capture-D5KV3Cu7.js} +1 -1
  65. package/static/app/assets/{Library-DJ8KioFM.js → Library-C9kyFkSt.js} +1 -1
  66. package/static/app/assets/{System-C0FIb3OO.js → System-VbChmX7r.js} +1 -1
  67. package/static/app/assets/index-DCh5AoXt.css +2 -0
  68. package/static/app/assets/index-DPdcPoF0.js +17 -0
  69. package/static/app/assets/{primitives-BywkNS3f.js → primitives-DFeanEV6.js} +1 -1
  70. package/static/app/assets/{textarea-CQ61Rycp.js → textarea-CD8UNKIy.js} +1 -1
  71. package/static/app/index.html +2 -2
  72. package/static/sw.js +1 -1
  73. package/lattice_brain/_kg_common.py +0 -11
  74. package/lattice_brain/discovery.py +0 -11
  75. package/lattice_brain/documents.py +0 -11
  76. package/lattice_brain/identity.py +0 -11
  77. package/lattice_brain/ingest.py +0 -11
  78. package/lattice_brain/network.py +0 -11
  79. package/lattice_brain/projection.py +0 -11
  80. package/lattice_brain/provenance.py +0 -11
  81. package/lattice_brain/retrieval.py +0 -11
  82. package/lattice_brain/schema.py +0 -11
  83. package/lattice_brain/store.py +0 -11
  84. package/lattice_brain/write_master.py +0 -11
  85. package/latticeai/brain/__init__.py +0 -36
  86. package/latticeai/brain/_kg_common.py +0 -17
  87. package/latticeai/brain/context.py +0 -17
  88. package/latticeai/brain/conversations.py +0 -17
  89. package/latticeai/brain/discovery.py +0 -17
  90. package/latticeai/brain/documents.py +0 -17
  91. package/latticeai/brain/identity.py +0 -17
  92. package/latticeai/brain/ingest.py +0 -17
  93. package/latticeai/brain/memory.py +0 -17
  94. package/latticeai/brain/network.py +0 -17
  95. package/latticeai/brain/projection.py +0 -17
  96. package/latticeai/brain/provenance.py +0 -17
  97. package/latticeai/brain/retrieval.py +0 -17
  98. package/latticeai/brain/schema.py +0 -17
  99. package/latticeai/brain/store.py +0 -17
  100. package/latticeai/brain/write_master.py +0 -17
  101. package/latticeai/services/agent_runtime.py +0 -11
  102. package/static/app/assets/index-Bh7IIlyY.js +0 -16
  103. package/static/app/assets/index-_M5aCv21.css +0 -2
@@ -23,6 +23,13 @@ from typing import Any, Dict, List, Optional
23
23
 
24
24
  from fastapi import HTTPException
25
25
 
26
+ from latticeai.services.process_audit import (
27
+ CommandConfirmationError,
28
+ append_process_audit_event,
29
+ command_plan,
30
+ require_command_confirmation,
31
+ )
32
+
26
33
 
27
34
  def _progress_payload(*args, **kwargs) -> Dict[str, object]:
28
35
  try:
@@ -440,8 +447,8 @@ def engine_support_status(engine: str) -> Dict[str, object]:
440
447
  return {"supported": True, "reason": None}
441
448
 
442
449
 
443
- def install_engine(engine: str) -> Dict[str, Any]:
444
- from latticeai.services.model_runtime import BASE_DIR, ENGINE_INSTALLERS, engine_installed
450
+ def _engine_install_command(engine: str) -> tuple[list[str], str, bool]:
451
+ from latticeai.services.model_runtime import BASE_DIR, ENGINE_INSTALLERS
445
452
 
446
453
  if engine not in ENGINE_INSTALLERS:
447
454
  raise HTTPException(status_code=400, detail="지원하지 않는 엔진입니다.")
@@ -449,14 +456,7 @@ def install_engine(engine: str) -> Dict[str, Any]:
449
456
  required_binary = installer.get("requires_binary")
450
457
  if required_binary and shutil.which(required_binary) is None:
451
458
  raise HTTPException(status_code=400, detail=f"{required_binary}가 설치되어 있지 않아 자동 설치할 수 없습니다.")
452
- command = installer["command"]
453
- run_kwargs = {
454
- "cwd": str(BASE_DIR),
455
- "capture_output": True,
456
- "text": True,
457
- "timeout": 900,
458
- "check": False,
459
- }
459
+ command = list(installer["command"])
460
460
 
461
461
  if engine == "vllm" and sys.platform == "darwin" and platform.machine() == "arm64":
462
462
  command = [
@@ -468,13 +468,69 @@ def install_engine(engine: str) -> Dict[str, Any]:
468
468
  "~/.venv-vllm-metal/bin/pip install -U pip setuptools wheel; "
469
469
  "~/.venv-vllm-metal/bin/pip install vllm-metal",
470
470
  ]
471
+ requires_admin = bool(command and command[0] in {"apt", "apt-get", "dnf", "pacman"})
472
+ return command, str(BASE_DIR), requires_admin
473
+
474
+
475
+ def engine_install_plan(engine: str) -> Dict[str, Any]:
476
+ command, cwd, requires_admin = _engine_install_command(engine)
477
+ return command_plan(
478
+ command,
479
+ name=f"engine:{engine}",
480
+ purpose="engine_install",
481
+ cwd=cwd,
482
+ requires_admin=requires_admin,
483
+ metadata={"engine": engine},
484
+ )
485
+
486
+
487
+ def install_engine(engine: str, confirmation_token: Optional[str] = None) -> Dict[str, Any]:
488
+ from latticeai.services.model_runtime import engine_installed
489
+
490
+ command, cwd, _requires_admin = _engine_install_command(engine)
491
+ plan = engine_install_plan(engine)
492
+ try:
493
+ require_command_confirmation(command, confirmation_token, cwd=cwd, purpose="engine_install")
494
+ except CommandConfirmationError as exc:
495
+ append_process_audit_event("engine_install", plan=plan, status="denied", error=str(exc))
496
+ raise HTTPException(
497
+ status_code=403,
498
+ detail={
499
+ "status": "confirmation_required",
500
+ "reason": str(exc),
501
+ "install_plan": plan,
502
+ },
503
+ ) from exc
504
+
505
+ run_kwargs = {
506
+ "cwd": cwd,
507
+ "capture_output": True,
508
+ "text": True,
509
+ "timeout": 900,
510
+ "check": False,
511
+ }
471
512
  try:
513
+ append_process_audit_event("engine_install", plan=plan, status="started")
472
514
  completed = subprocess.run(command, **run_kwargs)
473
515
  except subprocess.TimeoutExpired:
516
+ append_process_audit_event("engine_install", plan=plan, status="timeout")
474
517
  raise HTTPException(status_code=408, detail="엔진 설치 시간이 초과되었습니다.")
518
+ except Exception as exc:
519
+ append_process_audit_event("engine_install", plan=plan, status="error", error=str(exc))
520
+ raise
521
+ append_process_audit_event(
522
+ "engine_install",
523
+ plan=plan,
524
+ status="finished",
525
+ returncode=completed.returncode,
526
+ stdout=completed.stdout,
527
+ stderr=completed.stderr,
528
+ )
475
529
  result = {
476
530
  "engine": engine,
477
- "command": " ".join(command),
531
+ "command_hash": plan["command_hash"],
532
+ "command_preview": plan["command_preview"],
533
+ "install_plan": plan,
478
534
  "returncode": completed.returncode,
479
535
  "stdout": completed.stdout[-12000:],
480
536
  "stderr": completed.stderr[-12000:],
@@ -491,15 +547,25 @@ def install_engine(engine: str) -> Dict[str, Any]:
491
547
  if already_up:
492
548
  result["daemon_started"] = "already_running"
493
549
  else:
550
+ daemon_command = [ollama, "serve"]
551
+ daemon_plan = command_plan(
552
+ daemon_command,
553
+ name="engine:ollama:serve",
554
+ purpose="engine_daemon_start",
555
+ metadata={"engine": "ollama"},
556
+ )
494
557
  try:
558
+ append_process_audit_event("engine_daemon_start", plan=daemon_plan, status="started")
495
559
  subprocess.Popen(
496
- [ollama, "serve"],
560
+ daemon_command,
497
561
  stdout=subprocess.DEVNULL,
498
562
  stderr=subprocess.DEVNULL,
499
563
  start_new_session=True,
500
564
  )
565
+ append_process_audit_event("engine_daemon_start", plan=daemon_plan, status="spawned")
501
566
  result["daemon_started"] = True
502
567
  except Exception as exc:
568
+ append_process_audit_event("engine_daemon_start", plan=daemon_plan, status="error", error=str(exc))
503
569
  logging.warning("ollama serve spawn failed: %s", exc)
504
570
  result["daemon_started"] = False
505
571
  return result
@@ -589,6 +655,7 @@ __all__ = [
589
655
  "pull_ollama_model_with_progress",
590
656
  "get_ollama_pulled_models",
591
657
  "engine_support_status",
658
+ "engine_install_plan",
592
659
  "install_engine",
593
660
  "_smoke_test_loaded_model",
594
661
  ]
@@ -47,6 +47,7 @@ from .model_engines import (
47
47
  pull_ollama_model_with_progress as _pull_ollama_model_with_progress,
48
48
  get_ollama_pulled_models as _get_ollama_pulled_models,
49
49
  engine_support_status as _engine_support_status,
50
+ engine_install_plan as _engine_install_plan,
50
51
  install_engine as _install_engine,
51
52
  local_binary as _local_binary,
52
53
  vllm_executable as _vllm_executable,
@@ -88,6 +89,8 @@ class ModelRuntimeState:
88
89
  self.ENABLE_GRAPH = True
89
90
  self.AUTOLOAD_MODELS = False
90
91
  self.MODEL_IDLE_UNLOAD_SECONDS = 0
92
+ self.ALLOW_MODEL_DOWNLOADS = False
93
+ self.MODEL_DOWNLOAD_TIMEOUT = 300
91
94
  self.ALLOW_LOCAL_MODELS = True
92
95
  self.REQUIRE_AUTH = False
93
96
  self.INVITE_GATE_ENABLED = False
@@ -118,7 +121,8 @@ class ModelRuntimeState:
118
121
  """Internal no-warning sync used at init."""
119
122
  global router, APP_MODE, DEFAULT_HOST, DEFAULT_PORT, DATA_DIR, BASE_DIR
120
123
  global ENABLE_TELEGRAM, ENABLE_GRAPH, AUTOLOAD_MODELS, MODEL_IDLE_UNLOAD_SECONDS
121
- global ALLOW_LOCAL_MODELS, REQUIRE_AUTH, INVITE_GATE_ENABLED, ALLOW_PLAINTEXT_API_KEYS
124
+ global ALLOW_MODEL_DOWNLOADS, MODEL_DOWNLOAD_TIMEOUT, ALLOW_LOCAL_MODELS, REQUIRE_AUTH
125
+ global INVITE_GATE_ENABLED, ALLOW_PLAINTEXT_API_KEYS
122
126
  global CORS_ALLOW_NETWORK, PUBLIC_MODEL, LOCAL_MODEL, IS_PUBLIC_MODE
123
127
  global keyring, get_current_user, get_user_api_key
124
128
  router = self.router
@@ -131,6 +135,8 @@ class ModelRuntimeState:
131
135
  ENABLE_GRAPH = self.ENABLE_GRAPH
132
136
  AUTOLOAD_MODELS = self.AUTOLOAD_MODELS
133
137
  MODEL_IDLE_UNLOAD_SECONDS = self.MODEL_IDLE_UNLOAD_SECONDS
138
+ ALLOW_MODEL_DOWNLOADS = self.ALLOW_MODEL_DOWNLOADS
139
+ MODEL_DOWNLOAD_TIMEOUT = self.MODEL_DOWNLOAD_TIMEOUT
134
140
  ALLOW_LOCAL_MODELS = self.ALLOW_LOCAL_MODELS
135
141
  REQUIRE_AUTH = self.REQUIRE_AUTH
136
142
  INVITE_GATE_ENABLED = self.INVITE_GATE_ENABLED
@@ -159,7 +165,8 @@ def _env_bool(key: str, default: bool = False) -> bool:
159
165
  def _download_allowed(allow_download: bool = False) -> bool:
160
166
  # Prefer STATE (the source of truth) over bare module global for internal logic.
161
167
  autoload = getattr(STATE, "AUTOLOAD_MODELS", AUTOLOAD_MODELS)
162
- return bool(allow_download) or _env_bool("LATTICEAI_ALLOW_MODEL_DOWNLOADS", default=False) or bool(autoload)
168
+ configured = getattr(STATE, "ALLOW_MODEL_DOWNLOADS", ALLOW_MODEL_DOWNLOADS)
169
+ return bool(allow_download) or bool(configured) or bool(autoload)
163
170
 
164
171
 
165
172
  def _download_block(provider: str, model_name: str) -> None:
@@ -726,6 +733,13 @@ def ensure_llamacpp_server(model_name: str) -> None:
726
733
  return _ensure_llamacpp_server(model_name)
727
734
 
728
735
 
736
+ def _safe_engine_install_plan(engine: str) -> Optional[Dict[str, object]]:
737
+ try:
738
+ return _engine_install_plan(engine)
739
+ except Exception:
740
+ return None
741
+
742
+
729
743
  def engine_installed(engine: str) -> bool:
730
744
  if engine == "local_mlx":
731
745
  return bool(
@@ -847,6 +861,7 @@ def engine_status() -> List[Dict]:
847
861
  "installed": engine_installed("local_mlx"),
848
862
  "installable": True,
849
863
  "install_label": ENGINE_INSTALLERS["local_mlx"]["label"],
864
+ "install_plan": _safe_engine_install_plan("local_mlx"),
850
865
  "models": mlx_models,
851
866
  },
852
867
  {
@@ -857,6 +872,7 @@ def engine_status() -> List[Dict]:
857
872
  "installed": ollama_installed,
858
873
  "installable": True,
859
874
  "install_label": ENGINE_INSTALLERS["ollama"]["label"],
875
+ "install_plan": _safe_engine_install_plan("ollama"),
860
876
  "models": ollama_models,
861
877
  },
862
878
  ]
@@ -872,6 +888,7 @@ def engine_status() -> List[Dict]:
872
888
  "support_reason": support["reason"],
873
889
  "installable": support["supported"] and spec["id"] in ENGINE_INSTALLERS,
874
890
  "install_label": ENGINE_INSTALLERS.get(spec["id"], {}).get("label"),
891
+ "install_plan": _safe_engine_install_plan(spec["id"]) if spec["id"] in ENGINE_INSTALLERS else None,
875
892
  "requires": spec["requires"],
876
893
  "models": (
877
894
  vllm_models if spec["id"] == "vllm"
@@ -900,6 +917,7 @@ def engine_status() -> List[Dict]:
900
917
  "installed": engine_installed(provider),
901
918
  "installable": True,
902
919
  "install_label": ENGINE_INSTALLERS[provider]["label"],
920
+ "install_plan": _safe_engine_install_plan(provider),
903
921
  "requires": env_key,
904
922
  "models": provider_models,
905
923
  })
@@ -920,6 +938,8 @@ def runtime_features() -> Dict:
920
938
  "graph_enabled": s.ENABLE_GRAPH,
921
939
  "autoload_models": s.AUTOLOAD_MODELS,
922
940
  "model_idle_unload_seconds": s.MODEL_IDLE_UNLOAD_SECONDS,
941
+ "allow_model_downloads": s.ALLOW_MODEL_DOWNLOADS,
942
+ "model_download_timeout": s.MODEL_DOWNLOAD_TIMEOUT,
923
943
  "model_memory_policy": r.model_memory_policy() if r else None,
924
944
  "allow_local_models": s.ALLOW_LOCAL_MODELS,
925
945
  "security": {
@@ -944,8 +964,8 @@ def runtime_features() -> Dict:
944
964
  },
945
965
  }
946
966
 
947
- def install_engine(engine: str) -> Dict:
948
- return _install_engine(engine)
967
+ def install_engine(engine: str, confirmation_token: Optional[str] = None) -> Dict:
968
+ return _install_engine(engine, confirmation_token=confirmation_token)
949
969
 
950
970
 
951
971
  def _resolve_model_alias(model_id: str, engine: Optional[str] = None) -> str:
@@ -0,0 +1,208 @@
1
+ """Auditable command plans for installer and local process execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Iterable, Mapping, Optional, Sequence
12
+
13
+
14
+ _SECRET_RE = re.compile(
15
+ r"(api[_-]?key|access[_-]?token|auth[_-]?token|bearer|client[_-]?secret|password|secret|token)",
16
+ re.IGNORECASE,
17
+ )
18
+ _SENSITIVE_FLAGS = {
19
+ "--api-key",
20
+ "--access-token",
21
+ "--auth-token",
22
+ "--client-secret",
23
+ "--password",
24
+ "--secret",
25
+ "--token",
26
+ }
27
+
28
+
29
+ class CommandConfirmationError(ValueError):
30
+ """Raised when a process command lacks the expected execution token."""
31
+
32
+
33
+ def _default_audit_file() -> Path:
34
+ base = Path(os.environ.get("LATTICEAI_DATA_DIR") or Path.home() / ".ltcai")
35
+ return base / "process_audit.jsonl"
36
+
37
+
38
+ def _normalize_command(command: Sequence[Any]) -> list[str]:
39
+ return [str(part) for part in command]
40
+
41
+
42
+ def redact_command(command: Sequence[Any]) -> list[str]:
43
+ redacted: list[str] = []
44
+ redact_next = False
45
+ for raw in _normalize_command(command):
46
+ lower = raw.lower()
47
+ if redact_next:
48
+ redacted.append("[REDACTED]")
49
+ redact_next = False
50
+ continue
51
+ if lower in _SENSITIVE_FLAGS:
52
+ redacted.append(raw)
53
+ redact_next = True
54
+ continue
55
+ if "=" in raw:
56
+ key, _value = raw.split("=", 1)
57
+ if _SECRET_RE.search(key):
58
+ redacted.append(f"{key}=[REDACTED]")
59
+ continue
60
+ if _SECRET_RE.search(raw) and len(raw) > 32:
61
+ redacted.append("[REDACTED]")
62
+ continue
63
+ redacted.append(raw)
64
+ return redacted
65
+
66
+
67
+ def _digest_payload(payload: Mapping[str, Any]) -> str:
68
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
69
+ return hashlib.sha256(encoded).hexdigest()
70
+
71
+
72
+ def command_hash(command: Sequence[Any], *, cwd: Optional[str] = None) -> str:
73
+ return _digest_payload({"command": _normalize_command(command), "cwd": cwd or ""})
74
+
75
+
76
+ def confirmation_token(command: Sequence[Any], *, cwd: Optional[str] = None, purpose: str = "execute") -> str:
77
+ digest = _digest_payload(
78
+ {
79
+ "purpose": purpose,
80
+ "command": _normalize_command(command),
81
+ "cwd": cwd or "",
82
+ }
83
+ )
84
+ return digest[:16]
85
+
86
+
87
+ def command_plan(
88
+ command: Sequence[Any],
89
+ *,
90
+ name: str,
91
+ purpose: str = "installer",
92
+ cwd: Optional[str] = None,
93
+ requires_admin: bool = False,
94
+ metadata: Optional[Mapping[str, Any]] = None,
95
+ ) -> dict[str, Any]:
96
+ return {
97
+ "name": name,
98
+ "purpose": purpose,
99
+ "command_preview": redact_command(command),
100
+ "command_hash": command_hash(command, cwd=cwd),
101
+ "confirmation_token": confirmation_token(command, cwd=cwd, purpose=purpose),
102
+ "requires_admin": bool(requires_admin),
103
+ "cwd": cwd,
104
+ "metadata": dict(metadata or {}),
105
+ }
106
+
107
+
108
+ def command_plan_for_commands(
109
+ commands: Iterable[Sequence[Any]],
110
+ *,
111
+ name: str,
112
+ purpose: str = "installer",
113
+ cwd: Optional[str] = None,
114
+ metadata: Optional[Mapping[str, Any]] = None,
115
+ ) -> dict[str, Any]:
116
+ normalized = [_normalize_command(command) for command in commands]
117
+ digest = _digest_payload({"purpose": purpose, "commands": normalized, "cwd": cwd or ""})
118
+ return {
119
+ "name": name,
120
+ "purpose": purpose,
121
+ "command_count": len(normalized),
122
+ "command_hash": digest,
123
+ "confirmation_token": digest[:16],
124
+ "cwd": cwd,
125
+ "metadata": dict(metadata or {}),
126
+ }
127
+
128
+
129
+ def verify_command_confirmation(
130
+ command: Sequence[Any],
131
+ provided_token: Optional[str],
132
+ *,
133
+ cwd: Optional[str] = None,
134
+ purpose: str = "installer",
135
+ ) -> bool:
136
+ if not provided_token:
137
+ return False
138
+ return str(provided_token).strip() == confirmation_token(command, cwd=cwd, purpose=purpose)
139
+
140
+
141
+ def require_command_confirmation(
142
+ command: Sequence[Any],
143
+ provided_token: Optional[str],
144
+ *,
145
+ cwd: Optional[str] = None,
146
+ purpose: str = "installer",
147
+ ) -> None:
148
+ if not verify_command_confirmation(command, provided_token, cwd=cwd, purpose=purpose):
149
+ raise CommandConfirmationError("installer command requires a matching confirmation token")
150
+
151
+
152
+ def _preview_text(text: Optional[str], *, limit: int = 500) -> Optional[str]:
153
+ if text is None:
154
+ return None
155
+ value = str(text)[-limit:]
156
+ value = re.sub(r"(?i)(api[_-]?key|access[_-]?token|auth[_-]?token|password|secret|token)=\S+", r"\1=[REDACTED]", value)
157
+ return value
158
+
159
+
160
+ def _text_hash(text: Optional[str]) -> Optional[str]:
161
+ if text is None:
162
+ return None
163
+ return hashlib.sha256(str(text).encode("utf-8", errors="replace")).hexdigest()
164
+
165
+
166
+ def append_process_audit_event(
167
+ event_type: str,
168
+ *,
169
+ plan: Mapping[str, Any],
170
+ status: str,
171
+ user_email: Optional[str] = None,
172
+ returncode: Optional[int] = None,
173
+ stdout: Optional[str] = None,
174
+ stderr: Optional[str] = None,
175
+ error: Optional[str] = None,
176
+ audit_file: Optional[Path] = None,
177
+ extra: Optional[Mapping[str, Any]] = None,
178
+ ) -> None:
179
+ path = audit_file or _default_audit_file()
180
+ entry = {
181
+ "ts": time.time(),
182
+ "event_type": event_type,
183
+ "status": status,
184
+ "user_email": user_email,
185
+ "plan": {
186
+ "name": plan.get("name"),
187
+ "purpose": plan.get("purpose"),
188
+ "command_preview": plan.get("command_preview"),
189
+ "command_hash": plan.get("command_hash"),
190
+ "requires_admin": plan.get("requires_admin"),
191
+ "cwd": plan.get("cwd"),
192
+ "metadata": plan.get("metadata") or {},
193
+ },
194
+ "returncode": returncode,
195
+ "stdout_hash": _text_hash(stdout),
196
+ "stderr_hash": _text_hash(stderr),
197
+ "stdout_preview": _preview_text(stdout),
198
+ "stderr_preview": _preview_text(stderr),
199
+ "error": _preview_text(error),
200
+ "extra": dict(extra or {}),
201
+ }
202
+ try:
203
+ path.parent.mkdir(parents=True, exist_ok=True)
204
+ with path.open("a", encoding="utf-8") as fh:
205
+ fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n")
206
+ except Exception:
207
+ # Auditing must never make setup recovery paths unusable.
208
+ return
@@ -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 = "8.7.0"
21
+ PRODUCT_VERSION_TARGET = "8.9.0"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -76,10 +76,10 @@ PRODUCT_GATES: List[ProductGate] = [
76
76
  evidence=[
77
77
  "package.json::release:artifacts",
78
78
  "package.json::release:validate",
79
- "README.md::dist/ltcai-8.7.0-py3-none-any.whl",
80
- "README.md::dist/ltcai-8.7.0.tar.gz",
81
- "README.md::dist/ltcai-8.7.0.vsix",
82
- "README.md::ltcai-8.7.0.tgz",
79
+ "README.md::dist/ltcai-8.9.0-py3-none-any.whl",
80
+ "README.md::dist/ltcai-8.9.0.tar.gz",
81
+ "README.md::dist/ltcai-8.9.0.vsix",
82
+ "README.md::ltcai-8.9.0.tgz",
83
83
  "scripts/validate_release_artifacts.py",
84
84
  "scripts/release_smoke.py",
85
85
  "Dockerfile",
@@ -95,12 +95,12 @@ PRODUCT_GATES: List[ProductGate] = [
95
95
  title="Release story is documented and honest",
96
96
  evidence=[
97
97
  "README.md",
98
- "README.md::The current release is **8.7.0",
99
- "SECURITY.md::8.7.x (latest)",
100
- "vscode-extension/README.md::**8.7.0",
101
- "docs/CHANGELOG.md::## [8.7.0]",
98
+ "README.md::The current release is **8.9.0",
99
+ "SECURITY.md::8.9.x (latest)",
100
+ "vscode-extension/README.md::**8.9.0",
101
+ "docs/CHANGELOG.md::## [8.9.0]",
102
102
  "FEATURE_STATUS.md",
103
- "RELEASE_NOTES_v8.7.0.md",
103
+ "RELEASE_NOTES_v8.9.0.md",
104
104
  "latticeai/core/agent.py::SingleAgentRuntime",
105
105
  "latticeai/core/agent.py::AgentRuntime = SingleAgentRuntime",
106
106
  "lattice_brain/runtime/contracts.py::runtime-boundary/v1",
@@ -115,7 +115,7 @@ PRODUCT_GATES: List[ProductGate] = [
115
115
  id="ecosystem-path",
116
116
  title="Community and plugin growth path is explicit",
117
117
  evidence=[
118
- "docs/COMMUNITY_AND_PLUGINS.md::8.7.0",
118
+ "docs/COMMUNITY_AND_PLUGINS.md::8.9.0",
119
119
  "docs/PLUGIN_SDK.md",
120
120
  "plugins/README.md",
121
121
  "plugins/hello-world/plugin.json",