agent-devkit 0.1.0 → 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.
package/README.md CHANGED
@@ -26,7 +26,7 @@ agent doctor
26
26
  Expected version for this release:
27
27
 
28
28
  ```text
29
- agent 0.1.0
29
+ agent 0.1.5
30
30
  ```
31
31
 
32
32
  ## Quick Start
@@ -173,12 +173,19 @@ agent llm doctor openrouter
173
173
  ### Option F: Ollama local backend
174
174
 
175
175
  ```bash
176
+ agent ollama status
177
+ agent ollama models
178
+ agent ollama pull qwen2.5-coder --dry-run
179
+ agent ollama pull qwen2.5-coder --yes
176
180
  ollama serve
177
- ollama pull qwen2.5-coder
178
181
  agent llm configure ollama --base-url http://localhost:11434/v1 --model qwen2.5-coder --set-default
179
182
  agent llm doctor ollama
180
183
  ```
181
184
 
185
+ Ollama is treated as an operational worker for repetitive local tasks. Codex and
186
+ Claude remain the preferred coordinators and reviewers for high-level planning,
187
+ software changes, documents, automation decisions and final review.
188
+
182
189
  ### Switch or override the backend
183
190
 
184
191
  ```bash
@@ -186,6 +193,8 @@ agent llm list
186
193
  agent llm set-default codex-cli
187
194
  agent llm set-default claude-code
188
195
  agent llm set-default openai
196
+ agent llm disable ollama
197
+ agent llm enable ollama
189
198
  agent llm doctor
190
199
  ```
191
200
 
@@ -210,6 +219,17 @@ Natural-language example:
210
219
  agent "analise o problema relatado no card 9900"
211
220
  ```
212
221
 
222
+ If a required source or provider is not configured yet, prompt routing and
223
+ capability execution return the global `provider-configurator` setup wizard
224
+ instead of a hardcoded manual command:
225
+
226
+ ```bash
227
+ agent --json "analise o card 7914 do projeto sustentacao no azure"
228
+ agent --json run topdesk-orchestrator read-incident --number "I 2606 001"
229
+ ```
230
+
231
+ The wizard asks for opt-in and collects one configuration item at a time.
232
+
213
233
  Deterministic example:
214
234
 
215
235
  ```bash
@@ -222,6 +242,26 @@ Inspect a capability contract before running it:
222
242
  agent inspect azure-devops-orchestrator read-card
223
243
  ```
224
244
 
245
+ ## Local Decisions And Tool Control
246
+
247
+ Agent DevKit persists opt-in and opt-out decisions locally. You can inspect and
248
+ change tools, integrations, skills and LLM availability from commands or natural
249
+ language prompts:
250
+
251
+ ```bash
252
+ agent decisions list
253
+ agent tools disable azure-devops
254
+ agent tools enable azure-devops
255
+ agent integrations list
256
+ agent skills list
257
+ agent "mostre minhas decisoes"
258
+ agent "desative o azure devops por enquanto"
259
+ agent "reative o ollama"
260
+ ```
261
+
262
+ Decisions are stored under `~/.ai-devkit/config/decisions.json`; secret values
263
+ are not stored there.
264
+
225
265
  ## Configure LLM Backends
226
266
 
227
267
  Agent DevKit stores references to credentials, not secret values. API keys stay
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-devkit",
3
- "version": "0.1.0",
3
+ "version": "0.1.5",
4
4
  "description": "Agent DevKit CLI runtime for specialist AI agents, capabilities and provider-aware automations.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/runtime/README.md CHANGED
@@ -204,11 +204,17 @@ agent "roteie este pedido para o agente especialista adequado"
204
204
 
205
205
  ### Opcao F: usar Ollama local
206
206
 
207
- Inicie o Ollama localmente e configure um endpoint compativel com OpenAI:
207
+ O Agent DevKit consegue diagnosticar Ollama, listar modelos, planejar pull e
208
+ usar o backend local como trabalhador operacional. Claude/Codex continuam sendo
209
+ os coordenadores e revisores preferenciais para decisao, especificacao e entrega
210
+ final.
208
211
 
209
212
  ```bash
213
+ agent ollama status
214
+ agent ollama models
215
+ agent ollama pull qwen2.5-coder --dry-run
216
+ agent ollama pull qwen2.5-coder --yes
210
217
  ollama serve
211
- ollama pull qwen2.5-coder
212
218
  agent llm configure ollama --base-url http://localhost:11434/v1 --model qwen2.5-coder --set-default
213
219
  agent llm doctor ollama
214
220
  ```
@@ -226,6 +232,8 @@ agent llm list
226
232
  agent llm set-default codex-cli
227
233
  agent llm set-default claude-code
228
234
  agent llm set-default openai
235
+ agent llm disable ollama
236
+ agent llm enable ollama
229
237
  agent llm doctor
230
238
  ```
231
239
 
@@ -309,6 +317,43 @@ Exemplo de uso deterministico com uma capability:
309
317
  agent run azure-devops-orchestrator read-card --project "Projeto" --id 9900 --include-comments
310
318
  ```
311
319
 
320
+ ## Configuracao agentica e decisoes locais
321
+
322
+ Quando um prompt ou capability exige uma fonte ou provider ainda nao
323
+ configurado, o `agent` nao deve mais parar apenas sugerindo um comando manual.
324
+ Ele aciona o configurador global `provider-configurator`, retorna um wizard
325
+ agentico com opt-in, perguntas progressivas e retomada do prompt original:
326
+
327
+ ```bash
328
+ agent --json "analise o card 7914 do projeto sustentacao no azure"
329
+ agent --json run topdesk-orchestrator read-incident --number "I 2606 001"
330
+ ```
331
+
332
+ O usuario tambem pode controlar ferramentas, integracoes, skills e LLMs sem
333
+ editar arquivos diretamente:
334
+
335
+ ```bash
336
+ agent decisions list
337
+ agent tools list
338
+ agent tools disable azure-devops
339
+ agent tools enable azure-devops
340
+ agent integrations list
341
+ agent skills list
342
+ agent llm list
343
+ ```
344
+
345
+ As mesmas operacoes podem ser feitas por prompt:
346
+
347
+ ```bash
348
+ agent "mostre minhas decisoes"
349
+ agent "desative o azure devops por enquanto"
350
+ agent "reative o ollama"
351
+ ```
352
+
353
+ Se o usuario negar ou desativar uma ferramenta, a decisao fica persistida em
354
+ `~/.ai-devkit/config/decisions.json` e o agente segue sem usar essa ferramenta
355
+ nas proximas sessoes ate reativacao explicita.
356
+
312
357
  Comandos uteis:
313
358
 
314
359
  ```bash
@@ -263,12 +263,19 @@ agent llm doctor openrouter
263
263
  ### Ollama local
264
264
 
265
265
  ```bash
266
+ agent ollama status
267
+ agent ollama models
268
+ agent ollama pull qwen2.5-coder --dry-run
269
+ agent ollama pull qwen2.5-coder --yes
266
270
  ollama serve
267
- ollama pull qwen2.5-coder
268
271
  agent llm configure ollama --base-url http://localhost:11434/v1 --model qwen2.5-coder --set-default
269
272
  agent llm doctor ollama
270
273
  ```
271
274
 
275
+ Ollama e tratado como executor operacional local. Codex e Claude continuam como
276
+ coordenadores/revisores preferenciais para decisao, especificacao, codigo,
277
+ documentos, automacoes e fechamento de entrega.
278
+
272
279
  Backends suportados no MVP:
273
280
 
274
281
  - `openai`: API OpenAI ou endpoint OpenAI-compatible.
@@ -289,6 +296,8 @@ agent llm configure ollama --base-url http://localhost:11434/v1 --model qwen2.5-
289
296
  agent llm configure codex-cli --set-default
290
297
  agent llm configure claude-code --set-default
291
298
  agent llm set-default codex-cli
299
+ agent llm disable ollama
300
+ agent llm enable ollama
292
301
  agent llm doctor
293
302
  agent llm doctor openai
294
303
  ```
@@ -331,6 +340,33 @@ Use `--session-only` para validar uma configuracao sem escrever em
331
340
  `~/.ai-devkit/config.json`. Arquivos passados com `--env-file` precisam conter
332
341
  ao menos um campo reconhecido pelo provider selecionado.
333
342
 
343
+ Quando um prompt ou capability exige uma source/provider ausente, o runtime
344
+ aciona o configurador global `provider-configurator` e retorna um wizard
345
+ agentico com opt-in e perguntas progressivas em vez de sugerir um comando
346
+ hardcoded:
347
+
348
+ ```bash
349
+ agent --json "analise o card 7914 do projeto sustentacao no azure"
350
+ agent --json run topdesk-orchestrator read-incident --number "I 2606 001"
351
+ ```
352
+
353
+ ## Decisoes, ferramentas e integracoes
354
+
355
+ Opt-ins e opt-outs ficam em `~/.ai-devkit/config/decisions.json` e podem ser
356
+ gerenciados por comando ou prompt:
357
+
358
+ ```bash
359
+ agent decisions list
360
+ agent tools list
361
+ agent tools disable azure-devops
362
+ agent tools enable azure-devops
363
+ agent integrations list
364
+ agent skills list
365
+ agent "mostre minhas decisoes"
366
+ agent "desative o azure devops por enquanto"
367
+ agent "reative o ollama"
368
+ ```
369
+
334
370
  ## Credential Resolver
335
371
 
336
372
  O resolver de credenciais opera por referencias e origens, sem retornar valores
@@ -1,3 +1,3 @@
1
1
  """Public CLI implementation for AI DevKit."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.5"
@@ -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()