mycode-coding-agent 0.1.0__py3-none-any.whl

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 (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,279 @@
1
+ import asyncio
2
+ from collections.abc import Iterable
3
+ from dataclasses import dataclass, field, replace
4
+
5
+ from mycode.permissions import (
6
+ ConfirmationRequest,
7
+ Confirmer,
8
+ DefaultPermissionChecker,
9
+ PermissionChecker,
10
+ PermissionDecision,
11
+ PermissionRequest,
12
+ RejectingConfirmer,
13
+ ScopedApprovalState,
14
+ )
15
+ from mycode.tools.base import (
16
+ BaseTool,
17
+ SyncTool,
18
+ ToolArgumentValidationError,
19
+ ToolResult,
20
+ )
21
+
22
+
23
+ class DuplicateToolError(ValueError):
24
+ pass
25
+
26
+
27
+ class ToolNotFoundError(KeyError):
28
+ pass
29
+
30
+
31
+ @dataclass
32
+ class ToolRegistry:
33
+ _tools: dict[str, BaseTool] = field(default_factory=dict)
34
+ permission_checker: PermissionChecker = field(default_factory=DefaultPermissionChecker)
35
+ confirmer: Confirmer = field(default_factory=RejectingConfirmer)
36
+ scoped_approvals: ScopedApprovalState = field(default_factory=ScopedApprovalState)
37
+
38
+ @classmethod
39
+ def from_tools(
40
+ cls,
41
+ tools: Iterable[BaseTool],
42
+ *,
43
+ permission_checker: PermissionChecker | None = None,
44
+ confirmer: Confirmer | None = None,
45
+ ) -> "ToolRegistry":
46
+ registry = cls()
47
+ if permission_checker is not None:
48
+ registry.permission_checker = permission_checker
49
+ if confirmer is not None:
50
+ registry.confirmer = confirmer
51
+
52
+ for tool in tools:
53
+ registry.register(tool)
54
+
55
+ return registry
56
+
57
+ def register(self, tool: BaseTool) -> None:
58
+ if tool.name in self._tools:
59
+ raise DuplicateToolError(f"Tool already registered: {tool.name}")
60
+
61
+ self._tools[tool.name] = tool
62
+
63
+ def get(self, name: str) -> BaseTool | None:
64
+ return self._tools.get(name)
65
+
66
+ def require(self, name: str) -> BaseTool:
67
+ tool = self.get(name)
68
+
69
+ if tool is None:
70
+ raise ToolNotFoundError(f"Tool not found: {name}")
71
+
72
+ return tool
73
+
74
+ def run_tool(self, name: str, arguments: dict[str, object]) -> ToolResult:
75
+ tool = self.get(name)
76
+
77
+ if tool is None:
78
+ return ToolResult.failure(
79
+ error=f"Tool not found: {name}",
80
+ metadata={"tool_name": name},
81
+ )
82
+ if not isinstance(tool, SyncTool):
83
+ return ToolResult.failure(
84
+ error="Tool requires async execution path",
85
+ metadata={
86
+ "tool_name": name,
87
+ "reason": "async_execution_required",
88
+ },
89
+ )
90
+
91
+ try:
92
+ args = tool.parse_arguments(arguments)
93
+ except ToolArgumentValidationError as error:
94
+ return ToolResult.failure(
95
+ error="Invalid tool arguments",
96
+ metadata={"validation_errors": error.errors},
97
+ )
98
+
99
+ try:
100
+ request, decision = tool.check_permission(args, self.permission_checker)
101
+ except Exception as error: # noqa: BLE001 - normalize permission boundary
102
+ return ToolResult.failure(
103
+ error=f"Tool permission check failed: {error}",
104
+ metadata={
105
+ "tool_name": name,
106
+ "exception_type": type(error).__name__,
107
+ },
108
+ )
109
+
110
+ decision, permission_failure = _resolve_permission(
111
+ request,
112
+ decision,
113
+ self.confirmer,
114
+ self.scoped_approvals,
115
+ )
116
+ if permission_failure is not None:
117
+ return permission_failure
118
+
119
+ return _with_confirmation_metadata(tool.run_authorized(args, decision), decision)
120
+
121
+ def is_concurrency_safe(self, name: str) -> bool:
122
+ tool = self.get(name)
123
+ if tool is None:
124
+ return False
125
+
126
+ return bool(
127
+ getattr(tool, "concurrency_safe", False)
128
+ and getattr(tool, "capability", None) == "read"
129
+ and getattr(tool, "risk", None) == "low"
130
+ )
131
+
132
+ async def run_tool_async(
133
+ self,
134
+ name: str,
135
+ arguments: dict[str, object],
136
+ *,
137
+ permission_lock: asyncio.Lock,
138
+ ) -> ToolResult:
139
+ """Run any tool through the common async execution contract."""
140
+ tool = self.get(name)
141
+
142
+ if tool is None:
143
+ return ToolResult.failure(
144
+ error=f"Tool not found: {name}",
145
+ metadata={"tool_name": name},
146
+ )
147
+
148
+ try:
149
+ args = tool.parse_arguments(arguments)
150
+ except ToolArgumentValidationError as error:
151
+ return ToolResult.failure(
152
+ error="Invalid tool arguments",
153
+ metadata={"validation_errors": error.errors},
154
+ )
155
+
156
+ async with permission_lock:
157
+ try:
158
+ request, decision = tool.check_permission(
159
+ args,
160
+ self.permission_checker,
161
+ )
162
+ except Exception as error: # noqa: BLE001 - normalize permission boundary
163
+ return ToolResult.failure(
164
+ error=f"Tool permission check failed: {error}",
165
+ metadata={
166
+ "tool_name": name,
167
+ "exception_type": type(error).__name__,
168
+ },
169
+ )
170
+
171
+ decision, permission_failure = _resolve_permission(
172
+ request,
173
+ decision,
174
+ self.confirmer,
175
+ self.scoped_approvals,
176
+ )
177
+ if permission_failure is not None:
178
+ return permission_failure
179
+
180
+ try:
181
+ result = await tool.run_authorized_async(args, decision)
182
+ except Exception as error: # noqa: BLE001 - normalize tool boundary
183
+ result = ToolResult.failure(
184
+ error=f"Tool execution failed: {error}",
185
+ metadata={"exception_type": type(error).__name__},
186
+ )
187
+ return _with_confirmation_metadata(result, decision)
188
+
189
+ def list_tools(self) -> list[BaseTool]:
190
+ return list(self._tools.values())
191
+
192
+ def get_schemas(self) -> list[dict[str, object]]:
193
+ return [tool.get_schema() for tool in self.list_tools()]
194
+
195
+
196
+ def _with_confirmation_metadata(
197
+ result: ToolResult, decision: PermissionDecision,
198
+ ) -> ToolResult:
199
+ """Keep approval provenance even when a tool does not copy its decision."""
200
+ keys = (
201
+ "confirmation_status", "confirmation_scope", "confirmation_source",
202
+ "confirmation_message", "confirmation_metadata", "grant_scope",
203
+ )
204
+ metadata = {key: decision.metadata[key] for key in keys if key in decision.metadata}
205
+ if not metadata:
206
+ return result
207
+ return replace(result, metadata={**result.metadata, **metadata})
208
+
209
+
210
+ def _permission_failure(decision: PermissionDecision) -> ToolResult:
211
+ return ToolResult.failure(
212
+ error=decision.message or "Permission denied",
213
+ metadata={
214
+ "permission_status": decision.status,
215
+ "permission_reason": decision.reason,
216
+ **decision.metadata,
217
+ },
218
+ )
219
+
220
+
221
+ def _resolve_permission(
222
+ request: PermissionRequest,
223
+ decision: PermissionDecision,
224
+ confirmer: Confirmer,
225
+ scoped_approvals: ScopedApprovalState,
226
+ ) -> tuple[PermissionDecision, ToolResult | None]:
227
+ if decision.status == "deny":
228
+ return decision, _permission_failure(decision)
229
+
230
+ if decision.status != "ask":
231
+ return decision, None
232
+
233
+ grant_scope = scoped_approvals.allows_ask()
234
+ if grant_scope is not None:
235
+ return PermissionDecision.allow(
236
+ message=decision.message,
237
+ metadata={
238
+ **decision.metadata,
239
+ "confirmation_status": "approved",
240
+ "confirmation_scope": grant_scope,
241
+ "confirmation_source": "scoped_grant",
242
+ "grant_scope": grant_scope,
243
+ },
244
+ ), None
245
+
246
+ confirmation_request = ConfirmationRequest(
247
+ permission_request=request,
248
+ permission_decision=decision,
249
+ prompt=decision.message,
250
+ metadata=decision.metadata,
251
+ )
252
+ confirmation_result = confirmer.confirm(confirmation_request)
253
+ if confirmation_result.status == "rejected":
254
+ return decision, ToolResult.failure(
255
+ error=confirmation_result.message
256
+ or "Permission confirmation rejected",
257
+ metadata={
258
+ "permission_status": decision.status,
259
+ "permission_reason": decision.reason,
260
+ "confirmation_status": confirmation_result.status,
261
+ "confirmation_message": confirmation_result.message,
262
+ **decision.metadata,
263
+ "confirmation_metadata": confirmation_result.metadata,
264
+ },
265
+ )
266
+
267
+ assert confirmation_result.scope is not None
268
+ scoped_approvals.approve(confirmation_result.scope)
269
+ return PermissionDecision.allow(
270
+ message=decision.message,
271
+ metadata={
272
+ **decision.metadata,
273
+ "confirmation_status": confirmation_result.status,
274
+ "confirmation_scope": confirmation_result.scope,
275
+ "confirmation_source": "explicit",
276
+ "confirmation_message": confirmation_result.message,
277
+ "confirmation_metadata": confirmation_result.metadata,
278
+ },
279
+ ), None
@@ -0,0 +1,237 @@
1
+ import json
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from pydantic import Field, field_validator
6
+
7
+ from mycode.permissions import PermissionChecker, PermissionDecision, PermissionRequest
8
+ from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
9
+ from mycode.tools.command_executor import CommandExecutionArgs, execute_command
10
+ from mycode.tools.command_risk import CommandRiskAnalysis, analyze_command_risk
11
+ from mycode.tools.permission_metadata import with_permission_metadata
12
+ from mycode.tools.workspace import Workspace, WorkspacePathError
13
+
14
+
15
+ DEFAULT_TIMEOUT_SECONDS = 30.0
16
+ DEFAULT_MAX_OUTPUT_CHARS = 12000
17
+ MAX_TIMEOUT_SECONDS = 300.0
18
+ MAX_OUTPUT_CHARS = 200000
19
+ COMMAND_DESCRIPTION = (
20
+ "要执行的 argv 字符串数组。第一个元素是可执行程序,后续元素是参数。"
21
+ "不是 Shell 命令字符串,不支持 &&、||、|、>、< 等 Shell 操作符,也不会展开"
22
+ " *.py 等通配符。需要切换工作目录时使用 cwd,不要执行 cd。例如:"
23
+ "[\"pytest\", \"tests/test_a.py\", \"-q\"]。"
24
+ )
25
+ COMMAND_VALIDATION_ERROR = (
26
+ "command 必须是 argv 字符串数组,而不是 Shell 命令字符串。请使用 cwd 代替 cd;"
27
+ "不支持 &&、||、|、>、< 等 Shell 语法。"
28
+ )
29
+ CWD_DESCRIPTION = (
30
+ "命令执行时的工作目录,必须位于 workspace 内。需要切换目录时使用该参数,不要执行 cd。"
31
+ )
32
+
33
+
34
+ class RunCommandArgs(ToolArgs):
35
+ command: list[str] = Field(min_length=1, description=COMMAND_DESCRIPTION)
36
+ cwd: str = Field(default=".", min_length=1, description=CWD_DESCRIPTION)
37
+ timeout_seconds: float = Field(
38
+ default=DEFAULT_TIMEOUT_SECONDS,
39
+ gt=0,
40
+ le=MAX_TIMEOUT_SECONDS,
41
+ )
42
+ max_output_chars: int = Field(
43
+ default=DEFAULT_MAX_OUTPUT_CHARS,
44
+ ge=0,
45
+ le=MAX_OUTPUT_CHARS,
46
+ )
47
+
48
+ @field_validator("command", mode="before")
49
+ @classmethod
50
+ def normalize_json_encoded_command(cls, value: object) -> object:
51
+ """Accept one common Provider mistake without introducing shell parsing."""
52
+
53
+ if not isinstance(value, str):
54
+ if not isinstance(value, (list, tuple)) or not value or any(
55
+ not isinstance(part, str) for part in value
56
+ ):
57
+ raise ValueError(COMMAND_VALIDATION_ERROR)
58
+ return value
59
+ try:
60
+ decoded = json.loads(value)
61
+ except json.JSONDecodeError as error:
62
+ raise ValueError(COMMAND_VALIDATION_ERROR) from error
63
+ if not isinstance(decoded, list) or not decoded or any(
64
+ not isinstance(part, str) for part in decoded
65
+ ):
66
+ raise ValueError(COMMAND_VALIDATION_ERROR)
67
+ return decoded
68
+
69
+ @field_validator("command")
70
+ @classmethod
71
+ def command_parts_must_not_be_empty(cls, value: list[str]) -> list[str]:
72
+ if any(part == "" for part in value):
73
+ raise ValueError(COMMAND_VALIDATION_ERROR)
74
+ if any("\x00" in part for part in value):
75
+ raise ValueError(COMMAND_VALIDATION_ERROR)
76
+
77
+ return value
78
+
79
+
80
+ class RunCommandTool(PydanticTool[RunCommandArgs]):
81
+ name = "run_command"
82
+ description = (
83
+ "在 workspace 内执行非交互命令。command 使用结构化 argv,命令直接执行,不经过 Shell 解释。"
84
+ )
85
+ args_model = RunCommandArgs
86
+ capability = "command"
87
+ risk = "high"
88
+
89
+ def __init__(self, workspace: Workspace) -> None:
90
+ self.workspace = workspace
91
+
92
+ def build_permission_request(self, args: RunCommandArgs) -> PermissionRequest:
93
+ return PermissionRequest(
94
+ tool_name=self.name,
95
+ capability=self.capability,
96
+ action=self.name,
97
+ target=_display_command(args.command),
98
+ arguments=args.model_dump(),
99
+ description="Run a non-interactive command.",
100
+ )
101
+
102
+ def check_permission(
103
+ self,
104
+ args: RunCommandArgs,
105
+ permission_checker: PermissionChecker,
106
+ ) -> tuple[PermissionRequest, PermissionDecision]:
107
+ request = self.build_permission_request(args)
108
+ try:
109
+ resolved_cwd = self.workspace.resolve_path(args.cwd)
110
+ except WorkspacePathError:
111
+ return request, PermissionDecision.deny(
112
+ reason="outside_workspace",
113
+ message=f"Command working directory is outside workspace: {args.cwd}",
114
+ metadata=_command_metadata(
115
+ args=args,
116
+ workspace_root=self.workspace.root,
117
+ resolved_cwd=_resolve_candidate_path(self.workspace, args.cwd),
118
+ cwd_scope="outside_workspace",
119
+ ),
120
+ )
121
+
122
+ risk_analysis = analyze_command_risk(args.command)
123
+ metadata = _command_metadata(
124
+ args=args,
125
+ workspace_root=self.workspace.root,
126
+ resolved_cwd=resolved_cwd,
127
+ cwd_scope="inside_workspace",
128
+ risk_analysis=risk_analysis,
129
+ )
130
+
131
+ if not resolved_cwd.exists():
132
+ return request, PermissionDecision.deny(
133
+ reason="unsupported_operation",
134
+ message=f"Command working directory does not exist: {args.cwd}",
135
+ metadata=metadata,
136
+ )
137
+
138
+ if not resolved_cwd.is_dir():
139
+ return request, PermissionDecision.deny(
140
+ reason="unsupported_operation",
141
+ message=f"Command working directory is not a directory: {args.cwd}",
142
+ metadata=metadata,
143
+ )
144
+
145
+ if risk_analysis.decision == "deny":
146
+ return request, PermissionDecision.deny(
147
+ reason="dangerous_command",
148
+ message=risk_analysis.reason,
149
+ metadata=metadata,
150
+ )
151
+
152
+ decision = permission_checker.check(
153
+ request,
154
+ self.get_permission_profile(),
155
+ )
156
+
157
+ return request, with_permission_metadata(decision, metadata)
158
+
159
+ def run_authorized(
160
+ self,
161
+ args: RunCommandArgs,
162
+ decision: PermissionDecision,
163
+ ) -> ToolResult:
164
+ try:
165
+ cwd = Path(str(decision.metadata["resolved_cwd"]))
166
+ return execute_command(
167
+ args=CommandExecutionArgs(
168
+ command=args.command,
169
+ timeout_seconds=args.timeout_seconds,
170
+ max_output_chars=args.max_output_chars,
171
+ ),
172
+ cwd=cwd,
173
+ permission_metadata=decision.metadata,
174
+ permission_status=decision.status,
175
+ )
176
+ except Exception as error:
177
+ return ToolResult.failure(
178
+ error=f"Tool execution failed: {error}",
179
+ metadata={
180
+ **decision.metadata,
181
+ "permission_status": decision.status,
182
+ "exception_type": type(error).__name__,
183
+ },
184
+ )
185
+
186
+ def _run(self, args: RunCommandArgs) -> ToolResult:
187
+ return ToolResult.failure(
188
+ error="run_command must be run through ToolRegistry.run_tool().",
189
+ metadata={
190
+ "command": args.command,
191
+ "cwd": args.cwd,
192
+ "reason": "permission_required",
193
+ },
194
+ )
195
+
196
+
197
+ def _resolve_candidate_path(workspace: Workspace, path: str) -> Path:
198
+ requested_path = Path(path)
199
+ if requested_path.is_absolute():
200
+ return requested_path.resolve(strict=False)
201
+
202
+ return (workspace.root / requested_path).resolve(strict=False)
203
+
204
+
205
+ def _command_metadata(
206
+ *,
207
+ args: RunCommandArgs,
208
+ workspace_root: Path,
209
+ resolved_cwd: Path,
210
+ cwd_scope: str,
211
+ risk_analysis: CommandRiskAnalysis | None = None,
212
+ ) -> dict[str, object]:
213
+ metadata: dict[str, object] = {
214
+ "command": list(args.command),
215
+ "command_display": _display_command(args.command),
216
+ "cwd": args.cwd,
217
+ "resolved_cwd": resolved_cwd.as_posix(),
218
+ "workspace_root": workspace_root.as_posix(),
219
+ "cwd_scope": cwd_scope,
220
+ "timeout_seconds": args.timeout_seconds,
221
+ "max_output_chars": args.max_output_chars,
222
+ }
223
+ if risk_analysis is not None:
224
+ metadata.update(
225
+ {
226
+ "command_risk_category": risk_analysis.category,
227
+ "command_risk": risk_analysis.risk,
228
+ "command_risk_decision": risk_analysis.decision,
229
+ "command_risk_reason": risk_analysis.reason,
230
+ }
231
+ )
232
+
233
+ return metadata
234
+
235
+
236
+ def _display_command(command: list[str]) -> str:
237
+ return subprocess.list2cmdline(command)