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,163 @@
1
+ """Calendar MVP backed by local ICS files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import date, datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from cli.aikit.app_home import config_path, ensure_app_home
11
+ from cli.aikit.llm import load_config, save_config
12
+
13
+
14
+ DATE_COMPACT = "%Y%m%d"
15
+ DATETIME_COMPACT = "%Y%m%dT%H%M%S"
16
+
17
+
18
+ def configure_calendar(*, ics_path: str | None = None, timezone: str | None = None) -> dict[str, Any]:
19
+ if not ics_path:
20
+ return calendar_needs_input()
21
+ path = Path(ics_path).expanduser().resolve()
22
+ if not path.exists():
23
+ raise ValueError(f"calendar ics file not found: {ics_path}")
24
+ config = load_config()
25
+ calendar = config.setdefault("calendar", {})
26
+ calendar["provider"] = "ics"
27
+ calendar["source_ref"] = str(path)
28
+ if timezone:
29
+ calendar["timezone"] = timezone
30
+ written = save_config(config)
31
+ return {
32
+ "kind": "calendar-configure",
33
+ "status": "configured",
34
+ "provider": "ics",
35
+ "source_ref": str(path),
36
+ "timezone": timezone,
37
+ "config_path": str(written),
38
+ "stored_secret": False,
39
+ }
40
+
41
+
42
+ def calendar_today() -> dict[str, Any]:
43
+ today = date.today()
44
+ return calendar_list(today.isoformat(), today.isoformat(), label="today")
45
+
46
+
47
+ def calendar_tomorrow() -> dict[str, Any]:
48
+ tomorrow = date.today() + timedelta(days=1)
49
+ return calendar_list(tomorrow.isoformat(), tomorrow.isoformat(), label="tomorrow")
50
+
51
+
52
+ def calendar_list(date_from: str | None, date_to: str | None, *, label: str | None = None) -> dict[str, Any]:
53
+ config = load_config()
54
+ calendar = config.get("calendar") if isinstance(config.get("calendar"), dict) else {}
55
+ if calendar.get("provider") != "ics" or not calendar.get("source_ref"):
56
+ return calendar_needs_input()
57
+ start = parse_date(date_from) if date_from else date.today()
58
+ end = parse_date(date_to) if date_to else start
59
+ events = [
60
+ event
61
+ for event in parse_ics(Path(str(calendar["source_ref"])))
62
+ if event_overlaps(event, start, end)
63
+ ]
64
+ events.sort(key=lambda item: item.get("start") or "")
65
+ return {
66
+ "kind": "calendar",
67
+ "status": "ok",
68
+ "label": label,
69
+ "provider": "ics",
70
+ "source_ref": str(calendar["source_ref"]),
71
+ "from": start.isoformat(),
72
+ "to": end.isoformat(),
73
+ "events": events,
74
+ "count": len(events),
75
+ "sensitive": True,
76
+ "llm_safe": False,
77
+ }
78
+
79
+
80
+ def calendar_needs_input() -> dict[str, Any]:
81
+ ensure_app_home()
82
+ return {
83
+ "kind": "calendar",
84
+ "status": "needs-input",
85
+ "ok": False,
86
+ "requires_provider": True,
87
+ "config_path": str(config_path()),
88
+ "message": "Calendar provider is not configured.",
89
+ "next_steps": [
90
+ "Configure a local ICS file with `agent calendar configure --ics <path>`.",
91
+ "Then run `agent calendar today` or ask `agent qual minha agenda de hoje?`.",
92
+ ],
93
+ "exit_code": 2,
94
+ }
95
+
96
+
97
+ def parse_ics(path: Path) -> list[dict[str, Any]]:
98
+ text = unfold_ics(path.read_text(encoding="utf-8"))
99
+ events: list[dict[str, Any]] = []
100
+ for block in re.findall(r"BEGIN:VEVENT(.*?)END:VEVENT", text, re.DOTALL):
101
+ fields: dict[str, str] = {}
102
+ for raw_line in block.splitlines():
103
+ if ":" not in raw_line:
104
+ continue
105
+ key, value = raw_line.split(":", 1)
106
+ key = key.split(";", 1)[0].upper()
107
+ fields[key] = value.strip()
108
+ start = parse_ics_datetime(fields.get("DTSTART"))
109
+ end = parse_ics_datetime(fields.get("DTEND"))
110
+ events.append(
111
+ {
112
+ "uid": fields.get("UID"),
113
+ "summary": fields.get("SUMMARY") or "(sem titulo)",
114
+ "description": fields.get("DESCRIPTION"),
115
+ "location": fields.get("LOCATION"),
116
+ "start": start.isoformat() if start else None,
117
+ "end": end.isoformat() if end else None,
118
+ "all_day": bool(start and isinstance(start, date) and not isinstance(start, datetime)),
119
+ }
120
+ )
121
+ return events
122
+
123
+
124
+ def unfold_ics(text: str) -> str:
125
+ return re.sub(r"\r?\n[ \t]", "", text)
126
+
127
+
128
+ def parse_ics_datetime(value: str | None) -> date | datetime | None:
129
+ if not value:
130
+ return None
131
+ value = value.rstrip("Z")
132
+ for fmt in (DATETIME_COMPACT, DATE_COMPACT):
133
+ try:
134
+ parsed = datetime.strptime(value, fmt)
135
+ except ValueError:
136
+ continue
137
+ return parsed.date() if fmt == DATE_COMPACT else parsed
138
+ return None
139
+
140
+
141
+ def parse_date(value: str) -> date:
142
+ return datetime.strptime(value, "%Y-%m-%d").date()
143
+
144
+
145
+ def event_overlaps(event: dict[str, Any], start: date, end: date) -> bool:
146
+ raw_start = event.get("start")
147
+ if not raw_start:
148
+ return False
149
+ event_date = datetime.fromisoformat(raw_start).date() if "T" in raw_start else date.fromisoformat(raw_start)
150
+ return start <= event_date <= end
151
+
152
+
153
+ def calendar_summary(payload: dict[str, Any]) -> str:
154
+ if payload.get("status") != "ok":
155
+ return str(payload.get("message") or "")
156
+ if not payload.get("events"):
157
+ return "Nenhum compromisso encontrado no periodo."
158
+ lines = []
159
+ for item in payload["events"]:
160
+ start = item.get("start") or "-"
161
+ summary = item.get("summary") or "-"
162
+ lines.append(f"- {start}: {summary}")
163
+ return "\n".join(lines)
@@ -0,0 +1,291 @@
1
+ """Global configuration orchestration for missing providers and sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from cli.aikit.decision_store import is_disabled
10
+ from cli.aikit.providers import ProviderRegistryError, load_providers, provider_or_error
11
+
12
+
13
+ PROJECT_PATTERN = re.compile(r"(?i)\bprojeto\s+([A-Za-z0-9._ -]{2,80}?)(?:\s+no\b|\s+na\b|\s+do\b|\s+da\b|$)")
14
+
15
+
16
+ def provider_setup_wizard(
17
+ root: Path,
18
+ provider_id: str,
19
+ *,
20
+ prompt: str | None = None,
21
+ route: dict[str, Any] | None = None,
22
+ agent_id: str | None = None,
23
+ capability_id: str | None = None,
24
+ reason: str | None = None,
25
+ ) -> dict[str, Any]:
26
+ provider = load_provider(root, provider_id)
27
+ provider_name = str(provider.get("name") or provider_id)
28
+ intent = str((route or {}).get("intent") or capability_id or "provider-configuration")
29
+ suggested_project = infer_project(prompt or "")
30
+ source_context = bool(route and route.get("intent"))
31
+ suggested_source_id = suggest_source_id(provider_id, suggested_project)
32
+ disabled = is_disabled("tools", provider_id) or is_disabled("integrations", provider_id)
33
+ questions = build_questions(provider, suggested_project=suggested_project, suggested_source_id=suggested_source_id, include_source=source_context)
34
+ next_question = disabled_notice(provider_id, provider_name) if disabled else opt_in_question(provider_id, provider_name)
35
+ return {
36
+ "kind": "provider-setup-wizard",
37
+ "status": "denied-by-user" if disabled else "waiting-for-user",
38
+ "provider": provider_id,
39
+ "provider_name": provider_name,
40
+ "provider_kind": provider.get("kind"),
41
+ "intent": intent,
42
+ "agent_id": agent_id or (route or {}).get("agent_id"),
43
+ "capability_id": capability_id or (route or {}).get("capability_id"),
44
+ "reason": reason,
45
+ "resume_prompt": prompt,
46
+ "suggested_source_id": suggested_source_id if source_context else None,
47
+ "suggested_config": suggested_config(provider_id, suggested_project),
48
+ "next_question": next_question,
49
+ "questions": questions,
50
+ "credential_options": credential_options(provider),
51
+ "config_fields": public_config_fields(provider),
52
+ "auth_methods": public_auth_methods(provider),
53
+ "fallbacks": list(provider.get("fallbacks", []) or []),
54
+ "stores_secret": False,
55
+ "owner_agent": "provider-configurator",
56
+ "message": (
57
+ f"{provider_name} esta desativado. O agente seguira sem essa ferramenta ate voce reativar."
58
+ if disabled
59
+ else f"Para continuar, o agente precisa configurar ou autorizar {provider_name}."
60
+ ),
61
+ }
62
+
63
+
64
+ def provider_wizard_from_requirement(
65
+ root: Path,
66
+ provider_id: str,
67
+ *,
68
+ agent_id: str | None = None,
69
+ capability_id: str | None = None,
70
+ reason: str | None = None,
71
+ ) -> dict[str, Any]:
72
+ return provider_setup_wizard(
73
+ root,
74
+ provider_id,
75
+ agent_id=agent_id,
76
+ capability_id=capability_id,
77
+ reason=reason or "Capability provider requirement is not configured.",
78
+ )
79
+
80
+
81
+ def load_provider(root: Path, provider_id: str) -> dict[str, Any]:
82
+ try:
83
+ return provider_or_error(load_providers(root), provider_id)
84
+ except ProviderRegistryError:
85
+ return {
86
+ "id": provider_id,
87
+ "name": provider_id,
88
+ "kind": "unknown",
89
+ "config_fields": [],
90
+ "auth_methods": [],
91
+ "fallbacks": [],
92
+ }
93
+
94
+
95
+ def build_questions(
96
+ provider: dict[str, Any],
97
+ *,
98
+ suggested_project: str | None,
99
+ suggested_source_id: str,
100
+ include_source: bool,
101
+ ) -> list[dict[str, Any]]:
102
+ questions: list[dict[str, Any]] = []
103
+ for field in provider.get("config_fields", []) or []:
104
+ if not isinstance(field, dict):
105
+ continue
106
+ name = str(field.get("name") or "").strip()
107
+ if not name:
108
+ continue
109
+ suggested_value = suggested_value_for_field(provider, name, suggested_project)
110
+ questions.append(
111
+ {
112
+ "id": field_question_id(provider["id"], name),
113
+ "type": "text",
114
+ "text": question_text(provider, name, suggested_value=suggested_value),
115
+ "config_key": name,
116
+ "env": name,
117
+ "required": bool(field.get("required")),
118
+ "secret": bool(field.get("secret")),
119
+ "default": field.get("default"),
120
+ "suggested_value": suggested_value,
121
+ }
122
+ )
123
+ auth_methods = public_auth_methods(provider)
124
+ if auth_methods:
125
+ questions.append(
126
+ {
127
+ "id": f"{provider_slug(str(provider['id']))}_auth",
128
+ "type": "select",
129
+ "text": "Como deseja autenticar?",
130
+ "options": [method["id"] for method in auth_methods] + ["env", "file", "skip"],
131
+ "auth_methods": auth_methods,
132
+ }
133
+ )
134
+ if include_source:
135
+ questions.append(
136
+ {
137
+ "id": "source_id",
138
+ "type": "text",
139
+ "text": "Qual nome local deseja dar para esta fonte?",
140
+ "suggested_value": suggested_source_id,
141
+ }
142
+ )
143
+ questions.append(
144
+ {
145
+ "id": "default_for_intent",
146
+ "type": "confirm",
147
+ "text": "Posso usar esta fonte como padrao para este tipo de pedido?",
148
+ "default": True,
149
+ }
150
+ )
151
+ if not questions:
152
+ questions.append(
153
+ {
154
+ "id": f"{provider_slug(str(provider['id']))}_confirm",
155
+ "type": "confirm",
156
+ "text": f"Posso tentar usar {provider.get('name') or provider['id']} com a cadeia de credenciais nativa?",
157
+ "default": False,
158
+ }
159
+ )
160
+ return questions
161
+
162
+
163
+ def opt_in_question(provider_id: str, provider_name: str) -> dict[str, Any]:
164
+ return {
165
+ "id": f"{provider_slug(provider_id)}_opt_in",
166
+ "type": "confirm",
167
+ "text": f"Posso configurar ou autorizar {provider_name} para atender este pedido?",
168
+ "default": False,
169
+ }
170
+
171
+
172
+ def disabled_notice(provider_id: str, provider_name: str) -> dict[str, Any]:
173
+ return {
174
+ "id": f"{provider_slug(provider_id)}_disabled",
175
+ "type": "notice",
176
+ "text": f"{provider_name} esta desativado por decisao do usuario. Reative para usar esta integracao.",
177
+ "default": False,
178
+ }
179
+
180
+
181
+ def public_config_fields(provider: dict[str, Any]) -> list[dict[str, Any]]:
182
+ fields = []
183
+ for field in provider.get("config_fields", []) or []:
184
+ if not isinstance(field, dict):
185
+ continue
186
+ fields.append(
187
+ {
188
+ "name": field.get("name"),
189
+ "required": bool(field.get("required")),
190
+ "secret": bool(field.get("secret")),
191
+ "default": field.get("default"),
192
+ }
193
+ )
194
+ return fields
195
+
196
+
197
+ def public_auth_methods(provider: dict[str, Any]) -> list[dict[str, Any]]:
198
+ methods = []
199
+ for method in provider.get("auth_methods", []) or []:
200
+ if not isinstance(method, dict):
201
+ continue
202
+ methods.append(
203
+ {
204
+ "id": method.get("id"),
205
+ "label": method.get("label") or method.get("id"),
206
+ "native": bool(method.get("native")),
207
+ "config_fields": list(method.get("config_fields", []) or []),
208
+ "secret_fields": list(method.get("secret_fields", []) or []),
209
+ }
210
+ )
211
+ return methods
212
+
213
+
214
+ def credential_options(provider: dict[str, Any]) -> list[dict[str, Any]]:
215
+ options = []
216
+ for method in public_auth_methods(provider):
217
+ for secret_field in method.get("secret_fields") or []:
218
+ options.append(
219
+ {
220
+ "id": f"env:{secret_field}",
221
+ "label": f"Variavel de ambiente {secret_field}",
222
+ "env": secret_field,
223
+ "auth_method": method.get("id"),
224
+ "stores_secret": False,
225
+ }
226
+ )
227
+ options.extend(
228
+ [
229
+ {"id": "file", "label": "Arquivo local de credencial", "stores_secret": False},
230
+ {"id": "native", "label": "Cadeia nativa ou CLI ja autenticada", "stores_secret": False},
231
+ {"id": "skip", "label": "Ignorar esta ferramenta", "stores_secret": False},
232
+ ]
233
+ )
234
+ seen: set[str] = set()
235
+ unique = []
236
+ for option in options:
237
+ option_id = str(option.get("id"))
238
+ if option_id not in seen:
239
+ seen.add(option_id)
240
+ unique.append(option)
241
+ return unique
242
+
243
+
244
+ def suggested_config(provider_id: str, suggested_project: str | None) -> dict[str, Any]:
245
+ config: dict[str, Any] = {}
246
+ if provider_id == "azure-devops" and suggested_project:
247
+ config["project"] = suggested_project
248
+ config["AZURE_DEVOPS_PROJECT"] = suggested_project
249
+ return config
250
+
251
+
252
+ def suggested_value_for_field(provider: dict[str, Any], name: str, suggested_project: str | None) -> str | None:
253
+ if provider.get("id") == "azure-devops" and name == "AZURE_DEVOPS_PROJECT" and suggested_project:
254
+ return suggested_project
255
+ return None
256
+
257
+
258
+ def question_text(provider: dict[str, Any], name: str, *, suggested_value: str | None) -> str:
259
+ if provider.get("id") == "azure-devops" and name == "AZURE_DEVOPS_ORG":
260
+ return "Qual e a organizacao do Azure DevOps?"
261
+ if provider.get("id") == "azure-devops" and name == "AZURE_DEVOPS_PROJECT":
262
+ if suggested_value:
263
+ return f'O projeto e "{suggested_value}"? Se nao, informe o nome correto.'
264
+ return "Qual e o nome do projeto no Azure DevOps?"
265
+ label = name.replace("_", " ").lower()
266
+ return f"Informe {label} para {provider.get('name') or provider.get('id')}."
267
+
268
+
269
+ def field_question_id(provider_id: str, name: str) -> str:
270
+ return f"{provider_slug(provider_id)}_{provider_slug(name)}"
271
+
272
+
273
+ def infer_project(prompt: str) -> str | None:
274
+ match = PROJECT_PATTERN.search(prompt)
275
+ if not match:
276
+ return None
277
+ project = " ".join(match.group(1).strip(" .,_-").split())
278
+ return project or None
279
+
280
+
281
+ def suggest_source_id(provider: str, project: str | None) -> str:
282
+ parts = [provider]
283
+ if project:
284
+ parts.append(project)
285
+ raw = "-".join(parts)
286
+ normalized = re.sub(r"[^a-z0-9._-]+", "-", raw.lower()).strip("-")
287
+ return normalized or provider_slug(provider)
288
+
289
+
290
+ def provider_slug(provider: str) -> str:
291
+ return re.sub(r"[^a-z0-9]+", "_", provider.lower()).strip("_") or "provider"
@@ -0,0 +1,158 @@
1
+ """Persistent local decisions for tools, integrations, skills and LLMs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from cli.aikit.app_home import app_path, ensure_app_home
12
+
13
+
14
+ DECISION_VERSION = 1
15
+ VALID_CATEGORIES = {"tools", "integrations", "skills", "llms"}
16
+ VALID_STATES = {"enabled", "disabled_by_user", "denied_by_user", "needs_setup", "unavailable", "available", "requires_permission"}
17
+ ITEM_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
18
+
19
+
20
+ def decisions_path() -> Path:
21
+ ensure_app_home()
22
+ path = app_path("config", "decisions.json")
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ return path
25
+
26
+
27
+ def empty_decisions() -> dict[str, Any]:
28
+ return {"version": DECISION_VERSION, "items": {}}
29
+
30
+
31
+ def load_decisions() -> dict[str, Any]:
32
+ path = decisions_path()
33
+ if not path.exists():
34
+ return empty_decisions()
35
+ try:
36
+ data = json.loads(path.read_text(encoding="utf-8"))
37
+ except (OSError, json.JSONDecodeError):
38
+ return empty_decisions()
39
+ if not isinstance(data, dict):
40
+ return empty_decisions()
41
+ data.setdefault("version", DECISION_VERSION)
42
+ if not isinstance(data.get("items"), dict):
43
+ data["items"] = {}
44
+ return data
45
+
46
+
47
+ def save_decisions(data: dict[str, Any]) -> Path:
48
+ path = decisions_path()
49
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
50
+ return path
51
+
52
+
53
+ def list_decisions(category: str | None = None) -> dict[str, Any]:
54
+ data = load_decisions()
55
+ items = [public_decision(item) for item in data.get("items", {}).values() if not category or item.get("category") == category]
56
+ items.sort(key=lambda item: (item["category"], item["id"]))
57
+ return {
58
+ "kind": "decisions",
59
+ "status": "ok",
60
+ "category": category,
61
+ "path": str(decisions_path()),
62
+ "items": items,
63
+ }
64
+
65
+
66
+ def set_decision(
67
+ category: str,
68
+ item_id: str,
69
+ state: str,
70
+ *,
71
+ reason: str | None = None,
72
+ scope: str = "persistent",
73
+ ) -> dict[str, Any]:
74
+ validate_category(category)
75
+ validate_item_id(item_id)
76
+ if state not in VALID_STATES:
77
+ raise ValueError(f"invalid decision state: {state}")
78
+ data = load_decisions()
79
+ key = decision_key(category, item_id)
80
+ item = {
81
+ "key": key,
82
+ "category": category,
83
+ "id": item_id,
84
+ "state": state,
85
+ "scope": scope,
86
+ "reason": reason,
87
+ "updated_at": now_iso(),
88
+ }
89
+ existing = data["items"].get(key)
90
+ if isinstance(existing, dict) and existing.get("created_at"):
91
+ item["created_at"] = existing["created_at"]
92
+ else:
93
+ item["created_at"] = item["updated_at"]
94
+ data["items"][key] = item
95
+ path = save_decisions(data)
96
+ return {
97
+ "kind": "decision",
98
+ "status": "updated",
99
+ "path": str(path),
100
+ "item": public_decision(item),
101
+ "category": category,
102
+ "id": item_id,
103
+ "state": state,
104
+ }
105
+
106
+
107
+ def get_decision(category: str, item_id: str) -> dict[str, Any] | None:
108
+ validate_category(category)
109
+ validate_item_id(item_id)
110
+ item = load_decisions().get("items", {}).get(decision_key(category, item_id))
111
+ return public_decision(item) if isinstance(item, dict) else None
112
+
113
+
114
+ def reset_decisions(category: str | None = None) -> dict[str, Any]:
115
+ data = load_decisions()
116
+ if category:
117
+ validate_category(category)
118
+ data["items"] = {key: value for key, value in data.get("items", {}).items() if value.get("category") != category}
119
+ else:
120
+ data["items"] = {}
121
+ path = save_decisions(data)
122
+ return {"kind": "decisions-reset", "status": "reset", "category": category, "path": str(path)}
123
+
124
+
125
+ def is_disabled(category: str, item_id: str) -> bool:
126
+ decision = get_decision(category, item_id)
127
+ return bool(decision and decision.get("state") in {"disabled_by_user", "denied_by_user"})
128
+
129
+
130
+ def public_decision(item: dict[str, Any]) -> dict[str, Any]:
131
+ return {
132
+ "category": item.get("category"),
133
+ "id": item.get("id"),
134
+ "state": item.get("state"),
135
+ "scope": item.get("scope"),
136
+ "reason": item.get("reason"),
137
+ "created_at": item.get("created_at"),
138
+ "updated_at": item.get("updated_at"),
139
+ }
140
+
141
+
142
+ def decision_key(category: str, item_id: str) -> str:
143
+ return f"{category}:{item_id}"
144
+
145
+
146
+ def validate_category(category: str) -> None:
147
+ if category not in VALID_CATEGORIES:
148
+ available = ", ".join(sorted(VALID_CATEGORIES))
149
+ raise ValueError(f"invalid decision category: {category}. available: {available}")
150
+
151
+
152
+ def validate_item_id(item_id: str) -> None:
153
+ if not item_id or not ITEM_ID_PATTERN.fullmatch(item_id):
154
+ raise ValueError("item id must use letters, numbers, dots, dashes, underscores or colons")
155
+
156
+
157
+ def now_iso() -> str:
158
+ return datetime.now(timezone.utc).isoformat()
@@ -7,6 +7,7 @@ import os
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
+ from cli.aikit.app_home import APP_DIRS, app_home
10
11
  from cli.aikit.llm import config_path as llm_config_path
11
12
  from cli.aikit.llm import doctor_backends
12
13
  from cli.aikit.lock import lock_path, lock_status, read_lock
@@ -42,6 +43,14 @@ def runtime_diagnostics(
42
43
  "kind": "runtime-summary",
43
44
  "status": status,
44
45
  "root": str(root),
46
+ "app_home": str(app_home()),
47
+ "app_dirs": {
48
+ name: {
49
+ "path": str(app_home() / name),
50
+ "exists": (app_home() / name).is_dir(),
51
+ }
52
+ for name in APP_DIRS
53
+ },
45
54
  "install_home": str(install_home),
46
55
  "config_path": str(runtime_config_path()),
47
56
  "llm_config_path": str(llm_config_path()),