lictor 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 (66) hide show
  1. lictor/__init__.py +8 -0
  2. lictor/adapters/__init__.py +0 -0
  3. lictor/adapters/agents/__init__.py +0 -0
  4. lictor/adapters/agents/claude_cli.py +394 -0
  5. lictor/adapters/agents/fake.py +166 -0
  6. lictor/adapters/config/__init__.py +0 -0
  7. lictor/adapters/config/loader.py +338 -0
  8. lictor/adapters/config/models.py +345 -0
  9. lictor/adapters/config/profiles/__init__.py +0 -0
  10. lictor/adapters/config/profiles/python-uv.json +82 -0
  11. lictor/adapters/config/profiles/python_uv/__init__.py +0 -0
  12. lictor/adapters/config/profiles/python_uv/lictor_outcome.py +61 -0
  13. lictor/adapters/config/profiles/python_uv/lictor_project_path.py +18 -0
  14. lictor/adapters/readers/__init__.py +0 -0
  15. lictor/adapters/readers/exit_code.py +41 -0
  16. lictor/adapters/readers/junit.py +137 -0
  17. lictor/adapters/readers/mutation.py +68 -0
  18. lictor/adapters/runner/__init__.py +0 -0
  19. lictor/adapters/runner/fake.py +60 -0
  20. lictor/adapters/runner/subprocess_runner.py +161 -0
  21. lictor/adapters/spec/__init__.py +0 -0
  22. lictor/adapters/spec/gherkin_source.py +377 -0
  23. lictor/adapters/store/__init__.py +0 -0
  24. lictor/adapters/store/clock.py +49 -0
  25. lictor/adapters/store/jsonl_eventlog.py +61 -0
  26. lictor/adapters/vcs/__init__.py +0 -0
  27. lictor/adapters/vcs/git_cli.py +351 -0
  28. lictor/application/__init__.py +0 -0
  29. lictor/application/anchor.py +21 -0
  30. lictor/application/events.py +28 -0
  31. lictor/application/ports.py +266 -0
  32. lictor/application/run_files.py +292 -0
  33. lictor/application/seal.py +87 -0
  34. lictor/application/spec_history.py +77 -0
  35. lictor/application/usecases/__init__.py +0 -0
  36. lictor/application/usecases/agent_events.py +110 -0
  37. lictor/application/usecases/build_report.py +542 -0
  38. lictor/application/usecases/check_project.py +235 -0
  39. lictor/application/usecases/check_red.py +570 -0
  40. lictor/application/usecases/check_working_tree.py +116 -0
  41. lictor/application/usecases/commit_green.py +77 -0
  42. lictor/application/usecases/implement_scenario.py +216 -0
  43. lictor/application/usecases/init_project.py +86 -0
  44. lictor/application/usecases/preconditions.py +161 -0
  45. lictor/application/usecases/prepare_driver.py +246 -0
  46. lictor/application/usecases/quarantine.py +55 -0
  47. lictor/application/usecases/register_scenarios.py +104 -0
  48. lictor/application/usecases/verify_attempt.py +704 -0
  49. lictor/application/verification_env.py +334 -0
  50. lictor/bootstrap.py +289 -0
  51. lictor/cli.py +786 -0
  52. lictor/domain/__init__.py +0 -0
  53. lictor/domain/errors.py +83 -0
  54. lictor/domain/model.py +423 -0
  55. lictor/domain/routing.py +111 -0
  56. lictor/domain/rules.py +808 -0
  57. lictor/orchestration/__init__.py +0 -0
  58. lictor/orchestration/graph.py +851 -0
  59. lictor/prompts/driver.md +24 -0
  60. lictor/prompts/implement.md +22 -0
  61. lictor/py.typed +0 -0
  62. lictor-0.1.0.dist-info/METADATA +623 -0
  63. lictor-0.1.0.dist-info/RECORD +66 -0
  64. lictor-0.1.0.dist-info/WHEEL +4 -0
  65. lictor-0.1.0.dist-info/entry_points.txt +3 -0
  66. lictor-0.1.0.dist-info/licenses/LICENSE +190 -0
lictor/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Lictor: implementa escenarios Gherkin con un agente y los verifica de forma determinista."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("lictor")
7
+ except PackageNotFoundError: # a source tree that was never installed
8
+ __version__ = "0+desconocida"
File without changes
File without changes
@@ -0,0 +1,394 @@
1
+ """Claude CLI adapter executing claude -p non-interactively."""
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass, replace
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ from lictor.application.ports import CommandRunner
10
+ from lictor.domain.model import (
11
+ AgentResult,
12
+ AgentStatus,
13
+ AgentTask,
14
+ CostKind,
15
+ Health,
16
+ PermissionDenial,
17
+ Tokens,
18
+ )
19
+ from lictor.domain.rules import model_responded
20
+
21
+ AuthKind = Literal["subscription", "api_key"]
22
+ _HTTP_UNAUTHORIZED = 401
23
+ _HTTP_TOO_MANY_REQUESTS = 429
24
+ _AUTH_CHECK_TIMEOUT_S = 10
25
+ _VERSION_TIMEOUT_S = 10
26
+ _DEFAULT_BASE_TOOLS = ("Read", "Edit", "Write", "Glob", "Grep")
27
+ _DEFAULT_DISALLOWED_TOOLS = (
28
+ "Bash(git:*)",
29
+ "Read(.lictor/**)",
30
+ "Edit(.lictor/**)",
31
+ "Write(.lictor/**)",
32
+ )
33
+ # Automatic memory is on even with no setting sources: it would let one call configure the next.
34
+ _SETTINGS = json.dumps({"autoMemoryEnabled": False})
35
+ _NO_AUTO_MEMORY = "CLAUDE_CODE_DISABLE_AUTO_MEMORY"
36
+ # Where the CLI keeps its configuration and OAuth session: dropping it would log the agent out.
37
+ _KEPT_CLAUDE_VARIABLES = frozenset({"CLAUDE_CONFIG_DIR"})
38
+ # What the caller's virtual environment says, which is Lictor's and not the project's.
39
+ _CALLER_VENV_VARIABLES = ("VIRTUAL_ENV", "VIRTUAL_ENV_PROMPT")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class _Stream:
44
+ """What a call's stream of events says, read once."""
45
+
46
+ events: tuple[dict[str, Any], ...]
47
+ result: dict[str, Any] | None
48
+ rate_limit_status: str | None
49
+ cli_version: str
50
+
51
+
52
+ class ClaudeCLI:
53
+ """Executes Claude Code CLI commands non-interactively through a command runner."""
54
+
55
+ def __init__(
56
+ self,
57
+ runner: CommandRunner,
58
+ auth: AuthKind = "subscription",
59
+ cli_binary: str = "claude",
60
+ ) -> None:
61
+ self.runner = runner
62
+ self.auth: AuthKind = auth
63
+ self.cli_binary: str = cli_binary
64
+
65
+ def health(self) -> Health:
66
+ """Verify CLI availability and authentication configuration."""
67
+ res = self.runner.run(
68
+ [self.cli_binary, "--version"], cwd=Path.cwd(), timeout_s=_VERSION_TIMEOUT_S
69
+ )
70
+ if not res.started:
71
+ return Health(
72
+ ok=False,
73
+ cli_version="",
74
+ auth_status="none",
75
+ message=f"No se pudo ejecutar el CLI '{self.cli_binary}': {res.stderr.strip()}",
76
+ )
77
+ if res.timed_out or res.returncode != 0:
78
+ return Health(
79
+ ok=False,
80
+ cli_version="",
81
+ auth_status="error",
82
+ message=f"El CLI devolvió el código {res.returncode}: {res.stderr.strip()}",
83
+ )
84
+ version_str = res.stdout.strip()
85
+
86
+ if self.auth == "subscription":
87
+ return self._subscription_health(version_str)
88
+
89
+ return Health(ok=True, cli_version=version_str, auth_status="api_key")
90
+
91
+ def _subscription_health(self, version: str) -> Health:
92
+ if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"):
93
+ return Health(
94
+ ok=False,
95
+ cli_version=version,
96
+ auth_status="api_key_override",
97
+ message=(
98
+ "ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN present while auth is 'subscription'"
99
+ ),
100
+ )
101
+ logged_in = self._logged_in()
102
+ if logged_in is None:
103
+ return Health(
104
+ ok=False,
105
+ cli_version=version,
106
+ auth_status="error",
107
+ message="No se pudo comprobar la sesión del CLI",
108
+ )
109
+ if not logged_in:
110
+ return Health(
111
+ ok=False,
112
+ cli_version=version,
113
+ auth_status="none",
114
+ message="El CLI no tiene sesión iniciada",
115
+ )
116
+ return Health(ok=True, cli_version=version, auth_status="subscription")
117
+
118
+ def _logged_in(self) -> bool | None:
119
+ """Ask the CLI for its session, which costs no model call; None if it can't tell."""
120
+ res = self.runner.run(
121
+ [self.cli_binary, "auth", "status", "--json"],
122
+ cwd=Path.cwd(),
123
+ timeout_s=_AUTH_CHECK_TIMEOUT_S,
124
+ env=self._prepare_env(),
125
+ )
126
+ try:
127
+ data = json.loads(res.stdout)
128
+ except json.JSONDecodeError:
129
+ return None
130
+ logged_in = data.get("loggedIn") if isinstance(data, dict) else None
131
+ return logged_in if isinstance(logged_in, bool) else None
132
+
133
+ def _build_command(self, task: AgentTask) -> list[str]:
134
+ tools: list[str] = list(_DEFAULT_BASE_TOOLS)
135
+ for tool in task.allowed_tools:
136
+ if tool not in tools:
137
+ tools.append(tool)
138
+ # `--tools` takes names, not rules: `Bash(cmd:*)` makes Bash available, the rule limits it.
139
+ available = list(dict.fromkeys(tool.split("(", 1)[0] for tool in tools))
140
+
141
+ return [
142
+ self.cli_binary,
143
+ "-p",
144
+ task.prompt,
145
+ "--output-format",
146
+ "stream-json",
147
+ "--verbose",
148
+ "--model",
149
+ task.model,
150
+ "--max-turns",
151
+ str(task.max_turns),
152
+ # Only the listed tools exist for the agent: no web, subagents, schedules or messages.
153
+ "--tools",
154
+ ",".join(available),
155
+ # `acceptEdits` also lets through shell commands it takes for edits, such as `touch`;
156
+ # `dontAsk` refuses whatever no rule allows.
157
+ "--permission-mode",
158
+ "dontAsk",
159
+ "--allowedTools",
160
+ *tools,
161
+ "--disallowedTools",
162
+ *_DEFAULT_DISALLOWED_TOOLS,
163
+ # No user, project or local settings: their hooks, permissions and CLAUDE.md would
164
+ # make the run depend on the machine, and the agent can write the project's ones.
165
+ "--setting-sources",
166
+ "",
167
+ "--settings",
168
+ _SETTINGS,
169
+ "--strict-mcp-config",
170
+ "--no-session-persistence",
171
+ ]
172
+
173
+ def _prepare_env(self) -> dict[str, str]:
174
+ """The caller's environment, without what belongs to the caller's own session.
175
+
176
+ Launched from a Claude Code session, the agent would inherit its variables
177
+ (`CLAUDECODE`, the session id, the effort...), and from Lictor's own virtual environment
178
+ the variables that name it.
179
+ """
180
+ env = {
181
+ name: value
182
+ for name, value in os.environ.items()
183
+ if not (name.startswith("CLAUDE") and name not in _KEPT_CLAUDE_VARIABLES)
184
+ and name not in _CALLER_VENV_VARIABLES
185
+ }
186
+ env[_NO_AUTO_MEMORY] = "1"
187
+ if self.auth == "subscription":
188
+ env.pop("ANTHROPIC_API_KEY", None)
189
+ env.pop("ANTHROPIC_AUTH_TOKEN", None)
190
+ env["DISABLE_AUTOUPDATER"] = "1"
191
+ return env
192
+
193
+ def _extract_spec_conflict(self, text: str) -> str | None:
194
+ last_conflict: str | None = None
195
+ for line in text.splitlines():
196
+ stripped = line.strip()
197
+ if stripped.startswith("LICTOR: SPEC_CONFLICT"):
198
+ reason = stripped[len("LICTOR: SPEC_CONFLICT") :].strip()
199
+ last_conflict = reason if reason else "SPEC_CONFLICT"
200
+ return last_conflict
201
+
202
+ def _is_auth_failure(self, data: dict[str, Any]) -> bool:
203
+ if data.get("api_error_status") == _HTTP_UNAUTHORIZED:
204
+ return True
205
+ return data.get("terminal_reason") == "api_error" and self._logged_in() is False
206
+
207
+ def _determine_status(
208
+ self,
209
+ data: dict[str, Any],
210
+ rate_limit_status: str | None,
211
+ exit_code: int,
212
+ ) -> AgentStatus:
213
+ subtype = str(data.get("subtype", ""))
214
+ is_error = bool(data.get("is_error", False))
215
+ failed = is_error or exit_code != 0 or subtype == "error_during_execution"
216
+
217
+ if subtype == "error_max_turns":
218
+ return "max_turns"
219
+ if subtype == "rate_limited":
220
+ return "rate_limited"
221
+ if failed and data.get("api_error_status") == _HTTP_TOO_MANY_REQUESTS:
222
+ return "rate_limited"
223
+ if failed and rate_limit_status == "rejected":
224
+ return "rate_limited"
225
+ if is_error and self._is_auth_failure(data):
226
+ return "auth"
227
+ return "error" if failed else "ok"
228
+
229
+ def _cost_kind(self) -> CostKind:
230
+ return "notional_subscription" if self.auth == "subscription" else "estimated"
231
+
232
+ def _read_stream(self, stdout: str) -> _Stream:
233
+ """Read the events of the stream, skipping what isn't one."""
234
+ events: list[dict[str, Any]] = []
235
+ result: dict[str, Any] | None = None
236
+ rate_limit_status: str | None = None
237
+ cli_version = ""
238
+ for line in stdout.splitlines():
239
+ try:
240
+ event = json.loads(line)
241
+ except json.JSONDecodeError:
242
+ continue
243
+ if not isinstance(event, dict):
244
+ continue
245
+ events.append(event)
246
+ kind = event.get("type")
247
+ if kind == "result":
248
+ result = event
249
+ elif kind == "rate_limit_event":
250
+ info = event.get("rate_limit_info")
251
+ if isinstance(info, dict) and isinstance(info.get("status"), str):
252
+ rate_limit_status = info["status"]
253
+ elif kind == "system" and event.get("subtype") == "init":
254
+ cli_version = str(event.get("claude_code_version") or "")
255
+ return _Stream(tuple(events), result, rate_limit_status, cli_version)
256
+
257
+ def _read_denials(self, data: dict[str, Any]) -> tuple[PermissionDenial, ...]:
258
+ denials = data.get("permission_denials")
259
+ if not isinstance(denials, list):
260
+ return ()
261
+ parsed: list[PermissionDenial] = []
262
+ for denial in denials:
263
+ if not isinstance(denial, dict):
264
+ parsed.append(PermissionDenial(tool="", target=str(denial)))
265
+ continue
266
+ tool_input = denial.get("tool_input")
267
+ fields = tool_input if isinstance(tool_input, dict) else {}
268
+ target = fields.get("command") or fields.get("file_path") or ""
269
+ parsed.append(
270
+ PermissionDenial(tool=str(denial.get("tool_name", "")), target=str(target))
271
+ )
272
+ return tuple(parsed)
273
+
274
+ def _parse_agent_data(
275
+ self,
276
+ data: dict[str, Any],
277
+ rate_limit_status: str | None,
278
+ exit_code: int,
279
+ duration_ms: int,
280
+ raw_path: Path,
281
+ ) -> AgentResult:
282
+ # error_max_turns has no `result`, only an `errors` list.
283
+ errors = data.get("errors")
284
+ fallback = "; ".join(str(e) for e in errors) if isinstance(errors, list) else ""
285
+ result_text = str(data.get("result") or fallback)
286
+
287
+ status = self._determine_status(data, rate_limit_status, exit_code)
288
+ spec_conflict = self._extract_spec_conflict(result_text)
289
+
290
+ usage = data.get("usage") or {}
291
+ tokens = Tokens(
292
+ input=int(usage.get("input_tokens", 0)),
293
+ output=int(usage.get("output_tokens", 0)),
294
+ cache_read=int(usage.get("cache_read_input_tokens", 0)),
295
+ cache_write=int(usage.get("cache_creation_input_tokens", 0)),
296
+ )
297
+
298
+ cost_usd = float(data["total_cost_usd"]) if data.get("total_cost_usd") is not None else None
299
+ reported_duration = data.get("duration_ms")
300
+ effective_duration = (
301
+ int(reported_duration) if reported_duration is not None else duration_ms
302
+ )
303
+
304
+ session_id = data.get("session_id")
305
+ return AgentResult(
306
+ status=status,
307
+ spec_conflict=spec_conflict,
308
+ text=result_text,
309
+ session_id=str(session_id) if session_id is not None else None,
310
+ num_turns=int(data.get("num_turns", 0)),
311
+ duration_ms=effective_duration,
312
+ tokens=tokens,
313
+ cost_usd=cost_usd,
314
+ cost_kind=self._cost_kind(),
315
+ raw_path=raw_path,
316
+ permission_denials=self._read_denials(data),
317
+ exit_code=exit_code,
318
+ )
319
+
320
+ def _save_stderr(self, raw_path: Path, stderr: str) -> Path | None:
321
+ """Keep stderr beside the raw stdout, which each call already has for itself."""
322
+ if not stderr:
323
+ return None
324
+ stderr_path = raw_path.with_suffix(".stderr.log")
325
+ stderr_path.write_text(stderr, encoding="utf-8", errors="replace")
326
+ return stderr_path
327
+
328
+ def run(self, task: AgentTask) -> AgentResult:
329
+ """Run claude -p for the given task and map the output to AgentResult."""
330
+ out = self.runner.run(
331
+ self._build_command(task),
332
+ cwd=task.cwd,
333
+ timeout_s=task.timeout_s,
334
+ env=self._prepare_env(),
335
+ )
336
+
337
+ task.raw_path.parent.mkdir(parents=True, exist_ok=True)
338
+ # Whatever the call left, even cut short by the timeout, is the evidence of what it did.
339
+ task.raw_path.write_text(out.stdout, encoding="utf-8")
340
+ stderr_path = self._save_stderr(task.raw_path, out.stderr)
341
+ stream = self._read_stream(out.stdout)
342
+ common: dict[str, Any] = {
343
+ "duration_ms": out.duration_ms,
344
+ "cost_kind": self._cost_kind(),
345
+ "raw_path": task.raw_path,
346
+ "stderr_path": stderr_path,
347
+ "cli_version": stream.cli_version,
348
+ }
349
+ if out.timed_out:
350
+ return self._timed_out(task, stream, common)
351
+
352
+ exit_code = out.returncode
353
+ if stream.result is None:
354
+ return AgentResult(
355
+ status="rate_limited" if stream.rate_limit_status == "rejected" else "error",
356
+ text=(
357
+ f"El CLI no emitió el evento result (código de salida {exit_code}). "
358
+ f"Stderr: {out.stderr}\nStdout: {out.stdout}"
359
+ ),
360
+ exit_code=exit_code,
361
+ **common,
362
+ )
363
+
364
+ return replace(
365
+ self._parse_agent_data(
366
+ data=stream.result,
367
+ rate_limit_status=stream.rate_limit_status,
368
+ exit_code=exit_code,
369
+ duration_ms=out.duration_ms,
370
+ raw_path=task.raw_path,
371
+ ),
372
+ stderr_path=stderr_path,
373
+ cli_version=stream.cli_version,
374
+ )
375
+
376
+ @staticmethod
377
+ def _timed_out(task: AgentTask, stream: _Stream, common: dict[str, Any]) -> AgentResult:
378
+ """A model that wrote something ran out of time; one that never answered is the backend.
379
+
380
+ A backend that rejected the call for its rate limit before answering is that limit.
381
+ """
382
+ if model_responded(stream.events):
383
+ return AgentResult(
384
+ status="timeout", text=f"Sin tiempo tras {task.timeout_s} s", **common
385
+ )
386
+ if stream.rate_limit_status == "rejected":
387
+ return AgentResult(
388
+ status="rate_limited",
389
+ text=f"Límite de uso alcanzado; el modelo no respondió en {task.timeout_s} s",
390
+ **common,
391
+ )
392
+ return AgentResult(
393
+ status="error", text=f"El modelo no respondió en {task.timeout_s} s", **common
394
+ )
@@ -0,0 +1,166 @@
1
+ """Deterministic FakeExecutor adapter for testing without LLM quota."""
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ from lictor.domain.model import (
10
+ AgentResult,
11
+ AgentRole,
12
+ AgentStatus,
13
+ AgentTask,
14
+ Health,
15
+ Tokens,
16
+ )
17
+
18
+ FAKE_CLI_VERSION = "fake-1.0.0"
19
+
20
+ FakeMode = Literal["good", "fail", "cheat", "conflict", "rate_limit", "crash"]
21
+ FakeEffect = Callable[[AgentTask, str | None, int], AgentStatus | None]
22
+ _SCENARIO_TAG_RE = re.compile(r"([A-Z][A-Z0-9]*-[0-9]+)")
23
+
24
+
25
+ class FakeExecutor:
26
+ """Deterministic agent test double simulating LLM runs with configured scripts and modes."""
27
+
28
+ def __init__(
29
+ self,
30
+ mode: FakeMode = "good",
31
+ *,
32
+ scenario_modes: dict[str, FakeMode] | None = None,
33
+ fixtures_dir: Path | None = None,
34
+ files_to_write: dict[str, dict[str, str]] | None = None,
35
+ crash_on_role: AgentRole | None = None,
36
+ crash_on_scenario: str | None = None,
37
+ tokens_per_call: Tokens | None = None,
38
+ cost_per_call: float = 0.005,
39
+ duration_ms: int = 150,
40
+ effect: FakeEffect | None = None,
41
+ ) -> None:
42
+ self.default_mode = mode
43
+ self.scenario_modes = scenario_modes or {}
44
+ self.fixtures_dir = Path(fixtures_dir) if fixtures_dir else None
45
+ self.files_to_write = files_to_write or {}
46
+ self.crash_on_role = crash_on_role
47
+ self.crash_on_scenario = crash_on_scenario
48
+ self.tokens_per_call = tokens_per_call or Tokens(
49
+ input=100, output=50, cache_read=200, cache_write=20
50
+ )
51
+ self.cost_per_call = cost_per_call
52
+ self.duration_ms = duration_ms
53
+ self.effect = effect
54
+ self.calls: list[str] = []
55
+
56
+ def health(self) -> Health:
57
+ """Return healthy fake status."""
58
+ return Health(
59
+ ok=True,
60
+ cli_version=FAKE_CLI_VERSION,
61
+ auth_status="subscription",
62
+ message=None,
63
+ )
64
+
65
+ def _detect_scenario_id(self, prompt: str) -> str | None:
66
+ match = _SCENARIO_TAG_RE.search(prompt)
67
+ return match.group(1) if match else None
68
+
69
+ def _apply_filesystem_effects(
70
+ self,
71
+ mode: FakeMode,
72
+ cwd: Path,
73
+ role: str | None = None,
74
+ scenario_id: str | None = None,
75
+ ) -> None:
76
+ keys_to_apply: list[str] = [mode]
77
+ if role:
78
+ keys_to_apply.append(f"{mode}:{role}")
79
+ keys_to_apply.append(role)
80
+ if scenario_id:
81
+ keys_to_apply.append(f"{mode}:{scenario_id}")
82
+ keys_to_apply.append(scenario_id)
83
+
84
+ for key in keys_to_apply:
85
+ mode_files = self.files_to_write.get(key, {})
86
+ for rel_path, content in mode_files.items():
87
+ dest = cwd / rel_path
88
+ dest.parent.mkdir(parents=True, exist_ok=True)
89
+ dest.write_text(content, encoding="utf-8")
90
+
91
+ if self.fixtures_dir:
92
+ src_mode_dir = self.fixtures_dir / mode
93
+ if src_mode_dir.is_dir():
94
+ for p in src_mode_dir.rglob("*"):
95
+ if p.is_file():
96
+ rel = p.relative_to(src_mode_dir)
97
+ dest = cwd / rel
98
+ dest.parent.mkdir(parents=True, exist_ok=True)
99
+ dest.write_bytes(p.read_bytes())
100
+
101
+ def _build_mode_outcome(self, mode: FakeMode) -> tuple[AgentStatus, str | None, str]:
102
+ if mode == "conflict":
103
+ spec_conflict = "Escenario imposible o contradictorio con la especificación"
104
+ return "ok", spec_conflict, f"Analizando...\nLICTOR: SPEC_CONFLICT {spec_conflict}"
105
+ if mode == "rate_limit":
106
+ return "rate_limited", None, "API rate limit reached"
107
+ return "ok", None, "Fake execution completed"
108
+
109
+ def run(self, task: AgentTask) -> AgentResult:
110
+ """Execute a deterministic fake agent run according to active mode."""
111
+ scenario_id = self._detect_scenario_id(task.prompt) if task.role == "implement" else None
112
+ mode: FakeMode = self.default_mode
113
+ if scenario_id and scenario_id in self.scenario_modes:
114
+ mode = self.scenario_modes[scenario_id]
115
+
116
+ if self.crash_on_role and task.role == self.crash_on_role:
117
+ raise RuntimeError(f"Simulated FakeExecutor crash on role {task.role}")
118
+
119
+ if self.crash_on_scenario and scenario_id and scenario_id == self.crash_on_scenario:
120
+ raise RuntimeError(f"Simulated FakeExecutor crash on scenario {scenario_id}")
121
+
122
+ if mode == "crash":
123
+ raise RuntimeError("Simulated FakeExecutor crash")
124
+
125
+ call_key = scenario_id or task.role
126
+ self.calls.append(call_key)
127
+ self._apply_filesystem_effects(mode, task.cwd, role=task.role, scenario_id=scenario_id)
128
+ status, spec_conflict, result_text = self._build_mode_outcome(mode)
129
+ if self.effect is not None:
130
+ status = self.effect(task, scenario_id, self.calls.count(call_key)) or status
131
+
132
+ tokens = self.tokens_per_call if status != "rate_limited" else Tokens()
133
+ cost = self.cost_per_call if status != "rate_limited" else 0.0
134
+
135
+ raw_data: dict[str, Any] = {
136
+ "mode": mode,
137
+ "status": status,
138
+ "spec_conflict": spec_conflict,
139
+ "result": result_text,
140
+ "num_turns": 1 if status != "rate_limited" else 0,
141
+ "duration_ms": self.duration_ms,
142
+ "usage": {
143
+ "input_tokens": tokens.input,
144
+ "output_tokens": tokens.output,
145
+ "cache_read_input_tokens": tokens.cache_read,
146
+ "cache_creation_input_tokens": tokens.cache_write,
147
+ },
148
+ "total_cost_usd": cost,
149
+ }
150
+
151
+ task.raw_path.parent.mkdir(parents=True, exist_ok=True)
152
+ task.raw_path.write_text(json.dumps(raw_data, indent=2), encoding="utf-8")
153
+
154
+ return AgentResult(
155
+ status=status,
156
+ spec_conflict=spec_conflict,
157
+ text=result_text,
158
+ session_id="fake-session-id",
159
+ num_turns=1 if status != "rate_limited" else 0,
160
+ duration_ms=self.duration_ms,
161
+ tokens=tokens,
162
+ cost_usd=cost,
163
+ cost_kind="notional_subscription",
164
+ raw_path=task.raw_path,
165
+ cli_version=FAKE_CLI_VERSION,
166
+ )
File without changes