agent-devkit 0.0.4 → 0.1.5

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 (60) hide show
  1. package/README.md +42 -2
  2. package/package.json +1 -1
  3. package/runtime/README.md +50 -2
  4. package/runtime/agent +29 -13
  5. package/runtime/agents/README.md +3 -0
  6. package/runtime/agents/github-pr-reviewer/AGENTS.md +10 -0
  7. package/runtime/agents/github-pr-reviewer/README.md +7 -0
  8. package/runtime/agents/github-pr-reviewer/agent.yaml +33 -0
  9. package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/capability.yaml +20 -0
  10. package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/decision-rules.md +8 -0
  11. package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/workflow.md +9 -0
  12. package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/capability.yaml +20 -0
  13. package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/decision-rules.md +7 -0
  14. package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/workflow.md +8 -0
  15. package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/capability.yaml +20 -0
  16. package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/decision-rules.md +7 -0
  17. package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/workflow.md +8 -0
  18. package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/capability.yaml +20 -0
  19. package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/decision-rules.md +9 -0
  20. package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/workflow.md +10 -0
  21. package/runtime/agents/github-pr-reviewer/infra/README.md +7 -0
  22. package/runtime/agents/github-pr-reviewer/knowledge/context.md +7 -0
  23. package/runtime/agents/github-pr-reviewer/knowledge/system.md +17 -0
  24. package/runtime/agents/github-pr-reviewer/templates/pr-automation-output.md +10 -0
  25. package/runtime/agents/github-pr-reviewer/templates/pr-inspection-output.md +9 -0
  26. package/runtime/agents/github-pr-reviewer/templates/pr-list-output.md +9 -0
  27. package/runtime/agents/github-pr-reviewer/templates/pr-review-output.md +17 -0
  28. package/runtime/cli/README.md +37 -1
  29. package/runtime/cli/aikit/__init__.py +1 -1
  30. package/runtime/cli/aikit/aliases.py +196 -0
  31. package/runtime/cli/aikit/app_home.py +78 -0
  32. package/runtime/cli/aikit/audit.py +344 -0
  33. package/runtime/cli/aikit/calendar.py +163 -0
  34. package/runtime/cli/aikit/configuration_orchestrator.py +291 -0
  35. package/runtime/cli/aikit/decision_store.py +158 -0
  36. package/runtime/cli/aikit/diagnostics.py +9 -0
  37. package/runtime/cli/aikit/fallback.py +48 -2
  38. package/runtime/cli/aikit/github_pr.py +254 -0
  39. package/runtime/cli/aikit/identity.py +75 -0
  40. package/runtime/cli/aikit/llm.py +249 -48
  41. package/runtime/cli/aikit/main.py +1240 -32
  42. package/runtime/cli/aikit/memory.py +148 -8
  43. package/runtime/cli/aikit/model_router.py +70 -0
  44. package/runtime/cli/aikit/notifications.py +9 -0
  45. package/runtime/cli/aikit/ollama.py +237 -0
  46. package/runtime/cli/aikit/permissions.py +345 -0
  47. package/runtime/cli/aikit/personality.py +123 -0
  48. package/runtime/cli/aikit/provider_wizard.py +19 -0
  49. package/runtime/cli/aikit/providers.py +4 -5
  50. package/runtime/cli/aikit/review_gate.py +40 -0
  51. package/runtime/cli/aikit/scheduler.py +18 -0
  52. package/runtime/cli/aikit/sessions.py +396 -0
  53. package/runtime/cli/aikit/setup_wizard.py +12 -0
  54. package/runtime/cli/aikit/tasks.py +351 -0
  55. package/runtime/cli/aikit/toolchain.py +274 -0
  56. package/runtime/providers/calendar.yaml +28 -0
  57. package/runtime/providers/github.yaml +42 -0
  58. package/runtime/providers/local-notification.yaml +19 -0
  59. package/runtime/providers/local-scheduler.yaml +24 -0
  60. package/runtime/vendor/skills/napkin/napkin.md +13 -3
@@ -0,0 +1,196 @@
1
+ """Local command aliases for the canonical agent CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cli.aikit.app_home import app_home, ensure_app_home
13
+
14
+
15
+ ALIAS_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{1,63}$")
16
+ RESERVED_ALIASES = {"agent", "aikit", "ai-devkit", "python", "python3"}
17
+
18
+
19
+ def aliases_config_path() -> Path:
20
+ return app_home() / "config" / "aliases.json"
21
+
22
+
23
+ def aliases_bin_dir() -> Path:
24
+ return app_home() / "bin"
25
+
26
+
27
+ def load_aliases() -> dict[str, Any]:
28
+ path = aliases_config_path()
29
+ if not path.exists():
30
+ return {"version": 1, "aliases": {}}
31
+ try:
32
+ data = json.loads(path.read_text(encoding="utf-8"))
33
+ except (OSError, json.JSONDecodeError):
34
+ return {"version": 1, "aliases": {}}
35
+ if not isinstance(data, dict):
36
+ return {"version": 1, "aliases": {}}
37
+ aliases = data.get("aliases")
38
+ if not isinstance(aliases, dict):
39
+ data["aliases"] = {}
40
+ data.setdefault("version", 1)
41
+ return data
42
+
43
+
44
+ def save_aliases(config: dict[str, Any]) -> Path:
45
+ ensure_app_home()
46
+ path = aliases_config_path()
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
49
+ return path
50
+
51
+
52
+ def list_aliases() -> dict[str, Any]:
53
+ ensure_app_home()
54
+ config = load_aliases()
55
+ items = []
56
+ invalid = []
57
+ for name, item in sorted(config.get("aliases", {}).items()):
58
+ if not isinstance(item, dict):
59
+ continue
60
+ try:
61
+ alias = validate_alias_name(name)
62
+ except ValueError:
63
+ invalid.append(name)
64
+ continue
65
+ items.append(alias_payload(alias, item))
66
+ return {"kind": "aliases", "status": "ok", "config_path": str(aliases_config_path()), "items": items, "invalid": invalid}
67
+
68
+
69
+ def add_alias(name: str, *, force: bool = False) -> dict[str, Any]:
70
+ alias = validate_alias_name(name)
71
+ ensure_app_home()
72
+ target = alias_executable_path(alias)
73
+ existing = shutil.which(alias)
74
+ if existing and Path(existing).resolve() != target.resolve() and not force:
75
+ raise ValueError(f"alias command already exists on PATH: {existing}. Use --force to overwrite local alias only.")
76
+
77
+ config = load_aliases()
78
+ item = {"name": alias, "created_by": "agent-devkit"}
79
+ config.setdefault("aliases", {})[alias] = item
80
+ write_alias_shims(alias)
81
+ written_path = save_aliases(config)
82
+ payload = alias_payload(alias, item)
83
+ payload.update({"kind": "alias", "status": "added", "config_path": str(written_path)})
84
+ return payload
85
+
86
+
87
+ def remove_alias(name: str) -> dict[str, Any]:
88
+ alias = validate_alias_name(name)
89
+ config = load_aliases()
90
+ removed = bool(config.get("aliases", {}).pop(alias, None))
91
+ removed_paths: list[str] = []
92
+ for path in alias_paths(alias):
93
+ if path.exists():
94
+ path.unlink()
95
+ removed_paths.append(str(path))
96
+ written_path = save_aliases(config)
97
+ return {
98
+ "kind": "alias",
99
+ "status": "removed" if removed or removed_paths else "missing",
100
+ "name": alias,
101
+ "config_path": str(written_path),
102
+ "removed_paths": removed_paths,
103
+ }
104
+
105
+
106
+ def sync_aliases() -> dict[str, Any]:
107
+ ensure_app_home()
108
+ config = load_aliases()
109
+ synced: list[dict[str, Any]] = []
110
+ invalid: list[str] = []
111
+ for alias, item in sorted(config.get("aliases", {}).items()):
112
+ if not isinstance(item, dict):
113
+ continue
114
+ try:
115
+ safe_alias = validate_alias_name(alias)
116
+ except ValueError:
117
+ invalid.append(alias)
118
+ continue
119
+ write_alias_shims(safe_alias)
120
+ synced.append(alias_payload(safe_alias, item))
121
+ save_aliases(config)
122
+ return {"kind": "aliases", "status": "synced", "config_path": str(aliases_config_path()), "items": synced, "invalid": invalid}
123
+
124
+
125
+ def validate_alias_name(name: str, *, allow_reserved: bool = False) -> str:
126
+ alias = name.strip()
127
+ if not ALIAS_PATTERN.fullmatch(alias):
128
+ raise ValueError("alias must start with a letter and contain only letters, numbers, hyphen or underscore")
129
+ if alias.lower() in RESERVED_ALIASES and not allow_reserved:
130
+ raise ValueError(f"alias is reserved: {alias}")
131
+ return alias
132
+
133
+
134
+ def alias_payload(alias: str, item: dict[str, Any]) -> dict[str, Any]:
135
+ return {
136
+ "name": alias,
137
+ "path": str(alias_executable_path(alias)),
138
+ "cmd_path": str(aliases_bin_dir() / f"{alias}.cmd"),
139
+ "ps1_path": str(aliases_bin_dir() / f"{alias}.ps1"),
140
+ "created_by": item.get("created_by"),
141
+ }
142
+
143
+
144
+ def write_alias_shims(alias: str) -> None:
145
+ bin_dir = aliases_bin_dir()
146
+ bin_dir.mkdir(parents=True, exist_ok=True)
147
+ root = Path(__file__).resolve().parents[2]
148
+ agent_script = root / "agent"
149
+ python = sys.executable
150
+
151
+ posix = alias_executable_path(alias)
152
+ posix.write_text(
153
+ "\n".join(
154
+ [
155
+ "#!/usr/bin/env sh",
156
+ "set -eu",
157
+ f"AI_DEVKIT_INVOKED_AS={json.dumps(alias)} exec {json.dumps(python)} {json.dumps(str(agent_script))} \"$@\"",
158
+ "",
159
+ ]
160
+ ),
161
+ encoding="utf-8",
162
+ )
163
+ posix.chmod(0o755)
164
+
165
+ cmd = bin_dir / f"{alias}.cmd"
166
+ cmd.write_text(
167
+ "\r\n".join(
168
+ [
169
+ "@echo off",
170
+ f"set AI_DEVKIT_INVOKED_AS={alias}",
171
+ f'"{python}" "{agent_script}" %*',
172
+ "",
173
+ ]
174
+ ),
175
+ encoding="utf-8",
176
+ )
177
+
178
+ ps1 = bin_dir / f"{alias}.ps1"
179
+ ps1.write_text(
180
+ "\n".join(
181
+ [
182
+ f"$env:AI_DEVKIT_INVOKED_AS = {json.dumps(alias)}",
183
+ f"& {json.dumps(python)} {json.dumps(str(agent_script))} @args",
184
+ "",
185
+ ]
186
+ ),
187
+ encoding="utf-8",
188
+ )
189
+
190
+
191
+ def alias_executable_path(alias: str) -> Path:
192
+ return aliases_bin_dir() / alias
193
+
194
+
195
+ def alias_paths(alias: str) -> list[Path]:
196
+ return [alias_executable_path(alias), aliases_bin_dir() / f"{alias}.cmd", aliases_bin_dir() / f"{alias}.ps1"]
@@ -0,0 +1,78 @@
1
+ """Local AI DevKit application home and filesystem layout helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ APP_HOME_ENV = "AI_DEVKIT_CONFIG_HOME"
10
+ LEGACY_APP_HOME_ENV = "AIKIT_CONFIG_HOME"
11
+ DEFAULT_APP_HOME_NAME = ".ai-devkit"
12
+
13
+ APP_DIRS = (
14
+ "bin",
15
+ "config",
16
+ "memory",
17
+ "sessions",
18
+ "tasks",
19
+ "policies",
20
+ "audit",
21
+ "secrets",
22
+ "cache",
23
+ "logs",
24
+ "state",
25
+ )
26
+
27
+
28
+ def app_home() -> Path:
29
+ """Return the configured Agent DevKit home directory."""
30
+ raw = os.environ.get(APP_HOME_ENV) or os.environ.get(LEGACY_APP_HOME_ENV)
31
+ if raw:
32
+ return Path(raw).expanduser().resolve()
33
+ return (Path.home() / DEFAULT_APP_HOME_NAME).resolve()
34
+
35
+
36
+ def app_path(*parts: str) -> Path:
37
+ return app_home().joinpath(*parts)
38
+
39
+
40
+ def ensure_app_home() -> Path:
41
+ """Create the application home skeleton and return its path."""
42
+ home = app_home()
43
+ home.mkdir(parents=True, exist_ok=True)
44
+ for name in APP_DIRS:
45
+ (home / name).mkdir(parents=True, exist_ok=True)
46
+ return home
47
+
48
+
49
+ def config_path() -> Path:
50
+ return app_path("config.json")
51
+
52
+
53
+ def memory_home() -> Path:
54
+ return app_path("memory")
55
+
56
+
57
+ def sessions_home() -> Path:
58
+ return app_path("sessions")
59
+
60
+
61
+ def tasks_home() -> Path:
62
+ return app_path("tasks")
63
+
64
+
65
+ def audit_home() -> Path:
66
+ return app_path("audit")
67
+
68
+
69
+ def policies_home() -> Path:
70
+ return app_path("policies")
71
+
72
+
73
+ def secrets_home() -> Path:
74
+ return app_path("secrets")
75
+
76
+
77
+ def cache_home() -> Path:
78
+ return app_path("cache")
@@ -0,0 +1,344 @@
1
+ """Local audit trail for Agent DevKit CLI executions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import json
7
+ import re
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from cli.aikit.app_home import audit_home, ensure_app_home
14
+ from cli.aikit.memory import normalize_prompt, redact_secrets
15
+
16
+
17
+ AUDIT_VERSION = 1
18
+
19
+
20
+ def now_utc() -> datetime:
21
+ return datetime.now(timezone.utc)
22
+
23
+
24
+ def now_iso() -> str:
25
+ return now_utc().isoformat()
26
+
27
+
28
+ def ensure_audit_home() -> Path:
29
+ ensure_app_home()
30
+ home = audit_home()
31
+ home.mkdir(parents=True, exist_ok=True)
32
+ return home
33
+
34
+
35
+ def audit_day_home(day: str | None = None) -> Path:
36
+ resolved_day = day or now_utc().date().isoformat()
37
+ path = ensure_audit_home() / resolved_day
38
+ path.mkdir(parents=True, exist_ok=True)
39
+ return path
40
+
41
+
42
+ def record_audit(
43
+ *,
44
+ command: str | None,
45
+ args: dict[str, Any],
46
+ result: dict[str, Any] | None = None,
47
+ error: str | None = None,
48
+ ) -> dict[str, Any]:
49
+ created_at = now_iso()
50
+ execution_id = f"exec_{now_utc().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}"
51
+ prompt = extract_prompt(args)
52
+ safe_result = redact_value(result or {})
53
+ entry = {
54
+ "version": AUDIT_VERSION,
55
+ "id": execution_id,
56
+ "created_at": created_at,
57
+ "user": safe_user(),
58
+ "command": command,
59
+ "prompt": redact_secrets(prompt) if prompt else None,
60
+ "prompt_normalized": normalize_prompt(prompt) if prompt else None,
61
+ "session": extract_session(safe_result),
62
+ "route": safe_result.get("route"),
63
+ "agent": extract_agent(safe_result, args),
64
+ "providers": extract_providers(safe_result),
65
+ "sources": extract_sources(safe_result),
66
+ "commands": extract_commands(safe_result),
67
+ "permissions": extract_permissions(safe_result),
68
+ "llm_backends": extract_llm_backends(safe_result),
69
+ "token_estimate": extract_token_estimate(safe_result),
70
+ "external_actions": extract_external_actions(safe_result),
71
+ "result": summarize_result(safe_result),
72
+ "error": redact_secrets(error) if error else None,
73
+ "redaction_applied": True,
74
+ }
75
+ day_home = audit_day_home(created_at[:10])
76
+ json_path = day_home / f"{execution_id}.json"
77
+ md_path = day_home / f"{execution_id}.md"
78
+ json_path.write_text(json.dumps(entry, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
79
+ md_path.write_text(render_audit_md(entry), encoding="utf-8")
80
+ return {"id": execution_id, "json_path": str(json_path), "markdown_path": str(md_path)}
81
+
82
+
83
+ def list_audits(limit: int = 20) -> dict[str, Any]:
84
+ items: list[dict[str, Any]] = []
85
+ for path in sorted(ensure_audit_home().glob("*/*.json"), reverse=True):
86
+ try:
87
+ payload = json.loads(path.read_text(encoding="utf-8"))
88
+ except json.JSONDecodeError:
89
+ continue
90
+ if not isinstance(payload, dict):
91
+ continue
92
+ items.append(
93
+ {
94
+ "id": payload.get("id"),
95
+ "created_at": payload.get("created_at"),
96
+ "command": payload.get("command"),
97
+ "status": (payload.get("result") or {}).get("status"),
98
+ "ok": (payload.get("result") or {}).get("ok"),
99
+ "json_path": str(path),
100
+ "markdown_path": str(path.with_suffix(".md")),
101
+ }
102
+ )
103
+ if len(items) >= limit:
104
+ break
105
+ return {"kind": "audit", "status": "ok", "home": str(ensure_audit_home()), "items": items}
106
+
107
+
108
+ def show_audit(execution_id: str) -> dict[str, Any]:
109
+ path = find_audit_json(execution_id)
110
+ payload = json.loads(path.read_text(encoding="utf-8"))
111
+ return {
112
+ "kind": "audit-entry",
113
+ "status": "ok",
114
+ "id": payload.get("id"),
115
+ "entry": payload,
116
+ "json_path": str(path),
117
+ "markdown_path": str(path.with_suffix(".md")),
118
+ }
119
+
120
+
121
+ def export_audit(execution_id: str, *, fmt: str = "md") -> dict[str, Any]:
122
+ path = find_audit_json(execution_id)
123
+ if fmt == "json":
124
+ content = path.read_text(encoding="utf-8")
125
+ export_path = path
126
+ elif fmt == "md":
127
+ md_path = path.with_suffix(".md")
128
+ content = md_path.read_text(encoding="utf-8") if md_path.exists() else render_audit_md(json.loads(path.read_text(encoding="utf-8")))
129
+ export_path = md_path
130
+ else:
131
+ raise ValueError("--format must be md or json")
132
+ return {
133
+ "kind": "audit-export",
134
+ "status": "ok",
135
+ "id": execution_id,
136
+ "format": fmt,
137
+ "path": str(export_path),
138
+ "content": content,
139
+ }
140
+
141
+
142
+ def find_audit_json(execution_id: str) -> Path:
143
+ if not execution_id or "/" in execution_id or "\\" in execution_id:
144
+ raise ValueError(f"invalid audit execution id: {execution_id}")
145
+ matches = sorted(ensure_audit_home().glob(f"*/{execution_id}.json"))
146
+ if not matches:
147
+ prefix_matches = sorted(ensure_audit_home().glob(f"*/{execution_id}*.json"))
148
+ if len(prefix_matches) == 1:
149
+ return prefix_matches[0]
150
+ if len(prefix_matches) > 1:
151
+ raise ValueError(f"ambiguous audit execution id prefix: {execution_id}")
152
+ raise ValueError(f"audit execution not found: {execution_id}")
153
+ return matches[0]
154
+
155
+
156
+ def redact_value(value: Any) -> Any:
157
+ if isinstance(value, str):
158
+ return redact_secrets(value)
159
+ if isinstance(value, list):
160
+ return [redact_value(item) for item in value]
161
+ if isinstance(value, tuple):
162
+ return [redact_value(item) for item in value]
163
+ if isinstance(value, dict):
164
+ redacted: dict[str, Any] = {}
165
+ for key, item in value.items():
166
+ key_text = str(key)
167
+ if secret_key(key_text):
168
+ redacted[key_text] = "[REDACTED_SECRET]"
169
+ else:
170
+ redacted[key_text] = redact_value(item)
171
+ return redacted
172
+ return value
173
+
174
+
175
+ def secret_key(key: str) -> bool:
176
+ normalized = key.lower().replace("-", "_")
177
+ if normalized in {"token_estimate", "tokens", "prompt_tokens", "completion_tokens", "total_tokens"}:
178
+ return False
179
+ exact = {"token", "secret", "password", "passwd", "pwd", "api_key", "private_key", "pat"}
180
+ if normalized in exact:
181
+ return True
182
+ if normalized.endswith(("_token", "_secret", "_password", "_passwd", "_pwd", "_api_key", "_private_key", "_pat")):
183
+ return True
184
+ compact = re.sub(r"[^a-z0-9]+", "", key.lower())
185
+ if compact in {"tokenestimate", "tokens", "prompttokens", "completiontokens", "totaltokens"}:
186
+ return False
187
+ return any(marker in compact for marker in ("token", "secret", "password", "passwd", "apikey", "privatekey"))
188
+
189
+
190
+ def extract_prompt(args: dict[str, Any]) -> str | None:
191
+ prompt = args.get("prompt")
192
+ if isinstance(prompt, list):
193
+ return " ".join(str(item) for item in prompt).strip()
194
+ return str(prompt).strip() if prompt else None
195
+
196
+
197
+ def extract_session(result: dict[str, Any]) -> dict[str, Any] | None:
198
+ session = result.get("session")
199
+ if isinstance(session, dict):
200
+ return {
201
+ "id": session.get("id"),
202
+ "title": session.get("title"),
203
+ "project": session.get("project"),
204
+ "token_estimate": session.get("token_estimate"),
205
+ }
206
+ return None
207
+
208
+
209
+ def extract_agent(result: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]:
210
+ if isinstance(result.get("agent"), dict):
211
+ return result["agent"]
212
+ if result.get("agent_id"):
213
+ return {"id": result.get("agent_id")}
214
+ if args.get("agent"):
215
+ return {"id": args.get("agent")}
216
+ route = result.get("route") if isinstance(result.get("route"), dict) else {}
217
+ if route.get("agent_id"):
218
+ return {"id": route.get("agent_id"), "capability": route.get("capability_id")}
219
+ return {}
220
+
221
+
222
+ def extract_providers(result: dict[str, Any]) -> Any:
223
+ if result.get("providers") is not None:
224
+ return result.get("providers")
225
+ route = result.get("route") if isinstance(result.get("route"), dict) else {}
226
+ provider = result.get("provider") or route.get("provider") or result.get("source_provider")
227
+ return {"used": [provider], "missing": [], "skipped": []} if provider else {"used": [], "missing": [], "skipped": []}
228
+
229
+
230
+ def extract_sources(result: dict[str, Any]) -> list[Any]:
231
+ source = result.get("source")
232
+ if source:
233
+ return [source]
234
+ return []
235
+
236
+
237
+ def extract_commands(result: dict[str, Any]) -> list[Any]:
238
+ commands = []
239
+ command = result.get("command")
240
+ if command:
241
+ commands.append(command)
242
+ for attempt in result.get("llm_backend_attempts") or []:
243
+ if isinstance(attempt, dict) and attempt.get("command"):
244
+ commands.append(attempt["command"])
245
+ return commands
246
+
247
+
248
+ def extract_permissions(result: dict[str, Any]) -> list[Any]:
249
+ permissions = []
250
+ for key in ("permission", "permissions"):
251
+ if result.get(key):
252
+ permissions.append(result[key])
253
+ preview = result.get("preview") if isinstance(result.get("preview"), dict) else {}
254
+ if preview.get("permissions"):
255
+ permissions.append(preview["permissions"])
256
+ return permissions
257
+
258
+
259
+ def extract_llm_backends(result: dict[str, Any]) -> list[Any]:
260
+ attempts = result.get("llm_backend_attempts")
261
+ if isinstance(attempts, list):
262
+ return attempts
263
+ backend = result.get("llm_backend")
264
+ return [{"id": backend}] if backend else []
265
+
266
+
267
+ def extract_token_estimate(result: dict[str, Any]) -> int | None:
268
+ session = result.get("session") if isinstance(result.get("session"), dict) else {}
269
+ if session.get("token_estimate") is not None:
270
+ return int(session.get("token_estimate") or 0)
271
+ prompt_length = result.get("prompt_length")
272
+ return int(prompt_length / 4) if isinstance(prompt_length, int) else None
273
+
274
+
275
+ def extract_external_actions(result: dict[str, Any]) -> list[dict[str, Any]]:
276
+ actions: list[dict[str, Any]] = []
277
+ preview = result.get("preview") if isinstance(result.get("preview"), dict) else {}
278
+ if preview.get("external_writes"):
279
+ actions.append({"type": preview.get("action_type"), "external_writes": True})
280
+ if result.get("external_action"):
281
+ actions.append(result["external_action"])
282
+ return actions
283
+
284
+
285
+ def summarize_result(result: dict[str, Any]) -> dict[str, Any]:
286
+ return {
287
+ "kind": result.get("kind"),
288
+ "status": result.get("status"),
289
+ "ok": result.get("ok"),
290
+ "exit_code": result.get("exit_code"),
291
+ "message": result.get("message"),
292
+ "mode": result.get("mode"),
293
+ }
294
+
295
+
296
+ def safe_user() -> str:
297
+ try:
298
+ return redact_secrets(getpass.getuser())
299
+ except Exception:
300
+ return "unknown"
301
+
302
+
303
+ def render_audit_md(entry: dict[str, Any]) -> str:
304
+ lines = [
305
+ f"# Audit {entry.get('id')}",
306
+ "",
307
+ f"- Created at: `{entry.get('created_at')}`",
308
+ f"- User: `{entry.get('user')}`",
309
+ f"- Command: `{entry.get('command')}`",
310
+ f"- Status: `{(entry.get('result') or {}).get('status')}`",
311
+ f"- OK: `{(entry.get('result') or {}).get('ok')}`",
312
+ "",
313
+ "## Prompt",
314
+ "",
315
+ entry.get("prompt") or "-",
316
+ "",
317
+ "## Agent",
318
+ "",
319
+ "```json",
320
+ json.dumps(entry.get("agent") or {}, ensure_ascii=False, indent=2, sort_keys=True),
321
+ "```",
322
+ "",
323
+ "## Providers",
324
+ "",
325
+ "```json",
326
+ json.dumps(entry.get("providers") or {}, ensure_ascii=False, indent=2, sort_keys=True),
327
+ "```",
328
+ "",
329
+ "## Permissions",
330
+ "",
331
+ "```json",
332
+ json.dumps(entry.get("permissions") or [], ensure_ascii=False, indent=2, sort_keys=True),
333
+ "```",
334
+ "",
335
+ "## Result",
336
+ "",
337
+ "```json",
338
+ json.dumps(entry.get("result") or {}, ensure_ascii=False, indent=2, sort_keys=True),
339
+ "```",
340
+ ]
341
+ if entry.get("error"):
342
+ lines.extend(["", "## Error", "", str(entry["error"])])
343
+ lines.append("")
344
+ return "\n".join(lines)