ltcai 9.4.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.
- package/README.md +63 -40
- package/docs/CHANGELOG.md +57 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
- package/docs/DEVELOPMENT.md +8 -8
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/docs/mcp-tools.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/change_proposals.py +60 -0
- package/latticeai/api/chat_agent_http.py +2 -0
- package/latticeai/api/command_center.py +51 -0
- package/latticeai/app_factory.py +39 -0
- package/latticeai/cli/entrypoint.py +3 -1
- package/latticeai/core/agent.py +127 -13
- package/latticeai/core/agent_eval.py +260 -0
- package/latticeai/core/agent_trace.py +104 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/tool_governor.py +101 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/change_proposals.py +270 -0
- package/latticeai/services/command_center.py +433 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/review_queue.py +4 -1
- package/latticeai/tools/__init__.py +6 -1
- package/package.json +1 -1
- package/scripts/agent_eval.py +46 -0
- package/scripts/check_current_release_docs.mjs +2 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/{Act-t9oveJO7.js → Act-BkOEmwBi.js} +1 -1
- package/static/app/assets/{Brain-DP-gpcEJ.js → Brain-C9ITUsQ_.js} +1 -1
- package/static/app/assets/{Capture-DYknDKy8.js → Capture-C-ppTeud.js} +1 -1
- package/static/app/assets/{Library-DLyc_g8c.js → Library-CGQbgWTu.js} +1 -1
- package/static/app/assets/{System-BZgJ7tGu.js → System-djmj0n2_.js} +1 -1
- package/static/app/assets/{index-Cl4S_9Id.css → index-85wQvEie.css} +1 -1
- package/static/app/assets/index-AF0-4XVv.js +18 -0
- package/static/app/assets/{primitives-DawfkPR4.js → primitives-jbb2qv4Q.js} +1 -1
- package/static/app/assets/{textarea-a4Ir3SZS.js → textarea-CFoo0OxJ.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/static/app/assets/index-BeQ77vPs.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"]
|
|
@@ -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.
|
|
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
|
|
@@ -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"]
|