logsayer 0.6.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 (40) hide show
  1. logsayer/__init__.py +3 -0
  2. logsayer/__main__.py +4 -0
  3. logsayer/adapters/__init__.py +10 -0
  4. logsayer/adapters/base.py +21 -0
  5. logsayer/adapters/claude.py +22 -0
  6. logsayer/adapters/generate.py +39 -0
  7. logsayer/adapters/opencode.py +22 -0
  8. logsayer/adapters/registry.py +38 -0
  9. logsayer/cli.py +311 -0
  10. logsayer/config.py +62 -0
  11. logsayer/core/__init__.py +1 -0
  12. logsayer/core/audit.py +81 -0
  13. logsayer/core/checks.py +22 -0
  14. logsayer/core/fremen.py +90 -0
  15. logsayer/core/logbook.py +116 -0
  16. logsayer/core/paths.py +30 -0
  17. logsayer/core/project.py +84 -0
  18. logsayer/core/specs.py +39 -0
  19. logsayer/core/suk.py +155 -0
  20. logsayer/scaffold.py +104 -0
  21. logsayer/templates/00_index.md.j2 +4 -0
  22. logsayer/templates/AGENTS.md.j2 +29 -0
  23. logsayer/templates/adapters/claude/mentat.md.j2 +16 -0
  24. logsayer/templates/adapters/claude/navigator.md.j2 +16 -0
  25. logsayer/templates/adapters/claude/reverend-mother.md.j2 +17 -0
  26. logsayer/templates/adapters/claude/truthsayer.md.j2 +17 -0
  27. logsayer/templates/adapters/opencode/mentat.md.j2 +37 -0
  28. logsayer/templates/adapters/opencode/navigator.md.j2 +31 -0
  29. logsayer/templates/adapters/opencode/reverend-mother.md.j2 +35 -0
  30. logsayer/templates/adapters/opencode/truthsayer.md.j2 +35 -0
  31. logsayer/templates/audit_prompt.md.j2 +11 -0
  32. logsayer/templates/audit_report.md.j2 +20 -0
  33. logsayer/templates/hu.md.j2 +11 -0
  34. logsayer/templates/logsayer.toml.j2 +4 -0
  35. logsayer/templates/project_state.md.j2 +17 -0
  36. logsayer-0.6.0.dist-info/METADATA +156 -0
  37. logsayer-0.6.0.dist-info/RECORD +40 -0
  38. logsayer-0.6.0.dist-info/WHEEL +4 -0
  39. logsayer-0.6.0.dist-info/entry_points.txt +2 -0
  40. logsayer-0.6.0.dist-info/licenses/LICENSE +21 -0
logsayer/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """logsayer — CLI del sistema de 5 capas documentales para proyectos con agentes IA."""
2
+
3
+ __version__ = "0.6.0"
logsayer/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from logsayer.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,10 @@
1
+ """Adaptadores por agente: wrappers finos que llaman al motor único (spec §6).
2
+
3
+ Cada adaptador solo traduce la convención de archivos nativa de una
4
+ herramienta (opencode, Claude Code, ...) e invoca comandos del CLI.
5
+ """
6
+
7
+ from logsayer.adapters.base import AdapterFile, AdapterSpec
8
+ from logsayer.adapters.registry import REGISTRY
9
+
10
+ __all__ = ["AdapterFile", "AdapterSpec", "REGISTRY"]
@@ -0,0 +1,21 @@
1
+ """Contrato declarativo de los adaptadores por agente (spec §6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class AdapterFile:
10
+ """Archivo a generar para un adaptador: path relativo + template a renderizar."""
11
+
12
+ rel_path: str
13
+ template_name: str
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AdapterSpec:
18
+ """Definición completa de lo que genera un adaptador de agente."""
19
+
20
+ name: str
21
+ files: tuple[AdapterFile, ...]
@@ -0,0 +1,22 @@
1
+ """Adaptador para Claude Code: subagentes por rol en `.claude/agents/` (spec §6).
2
+
3
+ Cada archivo es un subagente en formato markdown de Claude (frontmatter
4
+ `name`/`description`/`tools` + system prompt) que invoca comandos del CLI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from logsayer.adapters.base import AdapterFile, AdapterSpec
10
+
11
+ ROLES: tuple[str, ...] = ("mentat", "navigator", "reverend-mother", "truthsayer")
12
+
13
+ claude_spec = AdapterSpec(
14
+ name="claude",
15
+ files=tuple(
16
+ AdapterFile(
17
+ f".claude/agents/{rol}.md",
18
+ f"adapters/claude/{rol}.md.j2",
19
+ )
20
+ for rol in ROLES
21
+ ),
22
+ )
@@ -0,0 +1,39 @@
1
+ """Generación de archivos de adaptador en la raíz del proyecto usuaria."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from jinja2 import Environment, PackageLoader, select_autoescape
8
+
9
+ from logsayer.adapters.base import AdapterSpec
10
+ from logsayer.config import LogsayerConfig
11
+ from logsayer.core.paths import require_logsayer_root
12
+
13
+
14
+ def generate_adapters(base: Path, spec: AdapterSpec) -> tuple[Path, list[Path]]:
15
+ """Escribe los subagentes del adaptador en la raíz logsayer de `base`.
16
+
17
+ Devuelve (raíz del proyecto, paths generados). Requiere raíz logsayer:
18
+ la estructura generada vive dentro de un proyecto ya scaffolded.
19
+ """
20
+ root = require_logsayer_root(base)
21
+ config = LogsayerConfig.load(root / "logsayer.toml")
22
+ env = Environment(
23
+ loader=PackageLoader("logsayer", "templates"),
24
+ autoescape=select_autoescape(("html", "xml")),
25
+ )
26
+ context = {
27
+ "project_name": root.name,
28
+ "audit_threshold_hus": config.audit_threshold_hus,
29
+ "bitacora_max_lines": config.bitacora_max_lines,
30
+ "context_threshold_percent": config.context_threshold_percent,
31
+ }
32
+ written: list[Path] = []
33
+ for adapter_file in spec.files:
34
+ rendered = env.get_template(adapter_file.template_name).render(**context)
35
+ target = root / adapter_file.rel_path
36
+ target.parent.mkdir(parents=True, exist_ok=True)
37
+ target.write_text(rendered, encoding="utf-8")
38
+ written.append(target)
39
+ return root, written
@@ -0,0 +1,22 @@
1
+ """Adaptador para opencode: subagentes por rol en `.opencode/agents/` (spec §6).
2
+
3
+ Cada archivo es un agente en formato markdown de opencode (frontmatter
4
+ `description`/`mode`/`permissions` + system prompt) que invoca comandos del CLI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from logsayer.adapters.base import AdapterFile, AdapterSpec
10
+
11
+ ROLES: tuple[str, ...] = ("mentat", "navigator", "reverend-mother", "truthsayer")
12
+
13
+ opencode_spec = AdapterSpec(
14
+ name="opencode",
15
+ files=tuple(
16
+ AdapterFile(
17
+ f".opencode/agents/{rol}.md",
18
+ f"adapters/opencode/{rol}.md.j2",
19
+ )
20
+ for rol in ROLES
21
+ ),
22
+ )
@@ -0,0 +1,38 @@
1
+ """Registro de adaptadores disponibles y resolución por nombre (spec §5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from logsayer.adapters.base import AdapterSpec
6
+ from logsayer.adapters.claude import claude_spec
7
+ from logsayer.adapters.opencode import opencode_spec
8
+
9
+ REGISTRY: dict[str, AdapterSpec] = {
10
+ "opencode": opencode_spec,
11
+ "claude": claude_spec,
12
+ }
13
+
14
+ # Conocidos por el spec §5 pero aún sin adaptador (fase 3: solo opencode y claude).
15
+ NOT_YET_IMPLEMENTED: frozenset[str] = frozenset(
16
+ {"copilot", "cursor", "gemini", "hermes"}
17
+ )
18
+
19
+ SUPPORTED: frozenset[str] = frozenset(REGISTRY)
20
+
21
+
22
+ class AgentError(Exception):
23
+ """Error controlado al resolver o generar un adaptador."""
24
+
25
+
26
+ def resolve_adapter(agent: str) -> AdapterSpec:
27
+ if agent in REGISTRY:
28
+ return REGISTRY[agent]
29
+ if agent in NOT_YET_IMPLEMENTED:
30
+ raise AgentError(
31
+ f"Adaptador {agent!r} aún no implementado. "
32
+ "Fase 3 soporta: " + ", ".join(sorted(SUPPORTED)) + "."
33
+ )
34
+ raise AgentError(
35
+ f"Agente desconocido {agent!r}. Soporta: "
36
+ + ", ".join(sorted(SUPPORTED))
37
+ + "."
38
+ )
logsayer/cli.py ADDED
@@ -0,0 +1,311 @@
1
+ """Interfaz de línea de comandos de logsayer (Typer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from logsayer.adapters.generate import generate_adapters
11
+ from logsayer.adapters.registry import AgentError, resolve_adapter
12
+ from logsayer.config import LogsayerConfig
13
+ from logsayer.core import audit, fremen, logbook, project, specs, suk
14
+ from logsayer.core.checks import CheckResult
15
+ from logsayer.core.paths import ProjectRootError, require_logsayer_root
16
+ from logsayer.core.project import project_name
17
+ from logsayer.scaffold import (
18
+ RENDERED_FILES,
19
+ ScaffoldError,
20
+ resolve_target,
21
+ scaffold,
22
+ )
23
+
24
+ app = typer.Typer(
25
+ help="logsayer — sistema de 5 capas documentales para proyectos con agentes IA.",
26
+ no_args_is_help=True,
27
+ )
28
+
29
+ _ROLES: tuple[tuple[str, str], ...] = (
30
+ ("mentat", "Capa 1 — Especificacion (Mentat)."),
31
+ ("navigator", "Capa 2 — Estado (Navegante)."),
32
+ ("reverend-mother", "Capa 3 — Bitacora (Reverenda Madre)."),
33
+ ("truthsayer", "Capa 4 — Verificacion semantica (Decidora)."),
34
+ ("suk", "Capa 4 — Verificacion mecanica (Suk Doctor)."),
35
+ ("fremen", "Capa 5 — Proceso (Fremen)."),
36
+ )
37
+
38
+ _role_typers: dict[str, typer.Typer] = {}
39
+ for _name, _help in _ROLES:
40
+ _role_typers[_name] = typer.Typer(help=_help, no_args_is_help=True)
41
+ app.add_typer(_role_typers[_name], name=_name)
42
+
43
+
44
+ def _register_with_alias(role: str, sub: typer.Typer, name: str) -> None:
45
+ """Registra `sub` bajo el grupo del rol y con alias plano en la raíz (spec §5)."""
46
+ _role_typers[role].add_typer(sub, name=name)
47
+ app.add_typer(sub, name=name)
48
+
49
+
50
+ @app.command()
51
+ def init(
52
+ project_name: Annotated[
53
+ str | None,
54
+ typer.Argument(help="Nombre del proyecto a scaffoldear."),
55
+ ] = None,
56
+ here: Annotated[
57
+ bool,
58
+ typer.Option("--here", help="Scaffoldea en el directorio actual."),
59
+ ] = False,
60
+ ) -> None:
61
+ """Inicializa la estructura docs/ + AGENTS.md + logsayer.toml."""
62
+ try:
63
+ target, name = resolve_target(project_name, here, Path.cwd())
64
+ existing = {rel for rel in RENDERED_FILES if (target / rel).is_file()}
65
+ written = scaffold(target, name, LogsayerConfig.load(), adopt=here)
66
+ except ScaffoldError as exc:
67
+ raise typer.BadParameter(str(exc)) from exc
68
+ typer.echo(f"Scaffold listo en {target}")
69
+ if here and existing:
70
+ typer.echo("Modo adopt: preservados (ya existían, no se sobrescriben):")
71
+ for rel in sorted(existing):
72
+ typer.echo(f" - {rel}")
73
+ for written_path in written:
74
+ typer.echo(f"Generado: {written_path}")
75
+
76
+
77
+ agent_typer = typer.Typer(
78
+ help="Adaptadores por agente (Capa: coordinación multi-agente).",
79
+ no_args_is_help=True,
80
+ )
81
+
82
+
83
+ @agent_typer.command("add")
84
+ def agent_add(
85
+ agent: Annotated[
86
+ str,
87
+ typer.Argument(help="Agente destino (opencode | claude)."),
88
+ ],
89
+ ) -> None:
90
+ """Genera subagentes por rol en la convención nativa del agente."""
91
+ try:
92
+ spec = resolve_adapter(agent)
93
+ root, written = generate_adapters(Path.cwd(), spec)
94
+ except (AgentError, ProjectRootError) as exc:
95
+ raise typer.BadParameter(str(exc)) from exc
96
+ for path in written:
97
+ typer.echo(f"Generado: {path.relative_to(root)}")
98
+
99
+
100
+ app.add_typer(agent_typer, name="agent")
101
+
102
+
103
+ spec_typer = typer.Typer(
104
+ help="Historias de usuario (Capa 1).",
105
+ no_args_is_help=True,
106
+ )
107
+
108
+
109
+ @spec_typer.command("new")
110
+ def spec_new(
111
+ hu: Annotated[str, typer.Argument(help="Identificador de la HU (ej: HU-01).")],
112
+ ) -> None:
113
+ """Crea una HU con template mínimo en 04_user_stories/."""
114
+ try:
115
+ root = require_logsayer_root(Path.cwd())
116
+ target = specs.create_hu(root, hu)
117
+ except (ProjectRootError, specs.SpecError) as exc:
118
+ raise typer.BadParameter(str(exc)) from exc
119
+ typer.echo(f"HU {hu} creada en {target}")
120
+
121
+
122
+ _register_with_alias("mentat", spec_typer, "spec")
123
+
124
+
125
+ state_typer = typer.Typer(
126
+ help="Snapshot del estado del proyecto (Capa 2).",
127
+ no_args_is_help=True,
128
+ )
129
+
130
+
131
+ @state_typer.command("show")
132
+ def state_show() -> None:
133
+ """Imprime docs/project_state.md (lectura obligatoria al iniciar sesión)."""
134
+ try:
135
+ root = require_logsayer_root(Path.cwd())
136
+ state = project.state_file(root)
137
+ except ProjectRootError as exc:
138
+ raise typer.BadParameter(str(exc)) from exc
139
+ if not state.is_file():
140
+ raise typer.BadParameter(f"No existe {state}. Se crea con 'logsayer init'.")
141
+ typer.echo(state.read_text(encoding="utf-8").rstrip())
142
+
143
+
144
+ _register_with_alias("navigator", state_typer, "state")
145
+
146
+
147
+ log_typer = typer.Typer(
148
+ help="Bitácora append-only, particionada (Capa 3).",
149
+ no_args_is_help=True,
150
+ )
151
+
152
+
153
+ @log_typer.command("add")
154
+ def log_add(
155
+ text: Annotated[str, typer.Argument(help="Texto de la entrada de bitácora.")],
156
+ phase: Annotated[
157
+ str | None,
158
+ typer.Option(
159
+ "--fase",
160
+ help="Fase del logbook (default: fase actual de project_state.md).",
161
+ ),
162
+ ] = None,
163
+ ) -> None:
164
+ """Agrega una entrada al logbook activo; particiona si supera el límite."""
165
+ try:
166
+ root = require_logsayer_root(Path.cwd())
167
+ config = LogsayerConfig.load(root / "logsayer.toml")
168
+ result = logbook.add_entry(root, text, config, phase_override=phase)
169
+ except (ProjectRootError, logbook.LogbookError) as exc:
170
+ raise typer.BadParameter(str(exc)) from exc
171
+ path = result["path"]
172
+ assert isinstance(path, Path)
173
+ typer.echo(f"Entrada registrada en {path.relative_to(root)}")
174
+
175
+
176
+ @log_typer.command("index")
177
+ def log_index() -> None:
178
+ """Reconstruye docs/logbooks/00_index.md desde los archivos reales."""
179
+ try:
180
+ root = require_logsayer_root(Path.cwd())
181
+ index_path = logbook.write_index(root)
182
+ except (ProjectRootError, logbook.LogbookError) as exc:
183
+ raise typer.BadParameter(str(exc)) from exc
184
+ typer.echo(f"Índice actualizado en {index_path.relative_to(root)}")
185
+
186
+
187
+ _register_with_alias("reverend-mother", log_typer, "log")
188
+
189
+
190
+ audit_typer = typer.Typer(
191
+ help="Verificación semántica spec-vs-código (Capa 4).",
192
+ no_args_is_help=True,
193
+ )
194
+
195
+
196
+ @audit_typer.command("run")
197
+ def audit_run(
198
+ reset_counter: Annotated[
199
+ bool,
200
+ typer.Option(
201
+ "--reset-counter",
202
+ help="Reinicia el contador de HUs en project_state.md tras la corrida.",
203
+ ),
204
+ ] = False,
205
+ ) -> None:
206
+ """Genera la estructura del reporte y el prompt; el resultado
207
+ lo produce la Decidora."""
208
+ try:
209
+ root = require_logsayer_root(Path.cwd())
210
+ report = audit.run_audit(root)
211
+ if reset_counter:
212
+ project.set_closed_hus(root, 0)
213
+ except (ProjectRootError, audit.AuditError, ValueError) as exc:
214
+ raise typer.BadParameter(str(exc)) from exc
215
+ typer.echo(f"Estructura y prompt generados en {report.relative_to(root)}")
216
+ if reset_counter:
217
+ typer.echo("Contador de HUs reiniciado a 0 en project_state.md.")
218
+ else:
219
+ typer.echo("Contador intacto. Al aprobar, corre de nuevo con --reset-counter.")
220
+
221
+
222
+ @audit_typer.command("status")
223
+ def audit_status() -> None:
224
+ """Muestra el contador de HUs vs el umbral y el último reporte."""
225
+ try:
226
+ root = require_logsayer_root(Path.cwd())
227
+ config = LogsayerConfig.load(root / "logsayer.toml")
228
+ closed = project.read_closed_hus(root)
229
+ last = audit.last_audit(root)
230
+ threshold = config.audit_threshold_hus
231
+ except ProjectRootError as exc:
232
+ raise typer.BadParameter(str(exc)) from exc
233
+ current = f"HUs cerradas desde la última auditoría: {closed}"
234
+ typer.echo(f"{current} (umbral: {threshold})")
235
+ if last is None:
236
+ typer.echo("Última auditoría: ninguna reportada.")
237
+ else:
238
+ typer.echo(f"Última auditoría: {last.name} ({last.relative_to(root)})")
239
+ if closed >= threshold:
240
+ typer.echo("Estado: corresponde auditar. Ejecuta: logsayer audit run")
241
+ else:
242
+ missing = threshold - closed
243
+ typer.echo(f"Estado: no corresponde auditar (faltan {missing} HUs).")
244
+
245
+
246
+ _register_with_alias("truthsayer", audit_typer, "audit")
247
+
248
+
249
+ def _render_checks(title: str, results: list[CheckResult]) -> None:
250
+ """Imprime el reporte de chequeos y sale con código 1 si algo falló."""
251
+ typer.echo(f"{title}\n")
252
+ failed = 0
253
+ for result in results:
254
+ mark = "✔" if result.ok else "✘"
255
+ typer.echo(f"{mark} {result.name}: {result.detail or 'OK'}")
256
+ if not result.ok:
257
+ failed += 1
258
+ typer.echo()
259
+ if failed:
260
+ message = (
261
+ f"Estado: {failed} chequeo(s) fallido(s). "
262
+ "Corrige antes de continuar."
263
+ )
264
+ typer.echo(message)
265
+ raise typer.Exit(code=1)
266
+ typer.echo("Estado: sano.")
267
+
268
+
269
+ @_role_typers["suk"].command("doctor")
270
+ def suk_doctor() -> None:
271
+ """Chequeo mecánico: estructura y no mezcla de capas (Suk Doctor)."""
272
+ try:
273
+ root = require_logsayer_root(Path.cwd())
274
+ results = suk.run_suk(root)
275
+ except ProjectRootError as exc:
276
+ raise typer.BadParameter(str(exc)) from exc
277
+ title = f"Suk Doctor — verificación mecánica de {project_name(root)}"
278
+ _render_checks(title, results)
279
+
280
+
281
+ @app.command("check")
282
+ def check() -> None:
283
+ """Alias plano de `suk doctor` (spec §5)."""
284
+ suk_doctor()
285
+
286
+
287
+ @_role_typers["fremen"].command("verify")
288
+ def fremen_verify() -> None:
289
+ """Chequeo de proceso: marco operativo y Definition of Ready (Fremen)."""
290
+ try:
291
+ root = require_logsayer_root(Path.cwd())
292
+ results = fremen.run_fremen(root)
293
+ except ProjectRootError as exc:
294
+ raise typer.BadParameter(str(exc)) from exc
295
+ title = f"Fremen — verificación de proceso de {project_name(root)}"
296
+ _render_checks(title, results)
297
+
298
+
299
+ process_typer = typer.Typer(
300
+ help="Chequeo de proceso (Capa 5 — Fremen).",
301
+ no_args_is_help=True,
302
+ )
303
+
304
+
305
+ @process_typer.command("check")
306
+ def process_check() -> None:
307
+ """Alias plano de `fremen verify` (spec §5)."""
308
+ fremen_verify()
309
+
310
+
311
+ app.add_typer(process_typer, name="process")
logsayer/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Carga y validación de la configuración `logsayer.toml` (tabla `[logsayer]`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ def _number(value: object, field: str) -> float:
11
+ if not isinstance(value, (int, float)):
12
+ raise ValueError(f"{field} debe ser un numero.")
13
+ return float(value)
14
+
15
+
16
+ def _integer(value: object, field: str) -> int:
17
+ if isinstance(value, bool) or not isinstance(value, int):
18
+ raise ValueError(f"{field} debe ser un entero.")
19
+ return value
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LogsayerConfig:
24
+ """Umbrales de configuración del framework (sección 4 de la spec maestra)."""
25
+
26
+ session_close_context_threshold: float = 0.70
27
+ bitacora_max_lines: int = 400
28
+ audit_threshold_hus: int = 3
29
+
30
+ @property
31
+ def context_threshold_percent(self) -> int:
32
+ return round(self.session_close_context_threshold * 100)
33
+
34
+ @classmethod
35
+ def load(cls, path: Path | None = None) -> LogsayerConfig:
36
+ """Carga `logsayer.toml`; si no existe o le falta `[logsayer]`, usa defaults."""
37
+ if path is None or not path.is_file():
38
+ return cls()
39
+ with path.open("rb") as fh:
40
+ data = tomllib.load(fh)
41
+ section = data.get("logsayer")
42
+ if section is None:
43
+ return cls()
44
+ if not isinstance(section, dict):
45
+ raise ValueError("La tabla [logsayer] debe ser un TOML table.")
46
+
47
+ threshold = _number(
48
+ section.get("session_close_context_threshold", 0.70),
49
+ "session_close_context_threshold",
50
+ )
51
+ if not 0.0 < threshold < 1.0:
52
+ raise ValueError("session_close_context_threshold debe estar entre 0 y 1.")
53
+ max_lines = _integer(
54
+ section.get("bitacora_max_lines", 400),
55
+ "bitacora_max_lines",
56
+ )
57
+ hus = _integer(section.get("audit_threshold_hus", 3), "audit_threshold_hus")
58
+ return cls(
59
+ session_close_context_threshold=threshold,
60
+ bitacora_max_lines=max_lines,
61
+ audit_threshold_hus=hus,
62
+ )
@@ -0,0 +1 @@
1
+ """Motor único de logsayer: lógica de dominio independiente del agente (spec §6)."""
logsayer/core/audit.py ADDED
@@ -0,0 +1,81 @@
1
+ """Auditoría spec-vs-código (Capa 4 — Decidora de Verdad).
2
+
3
+ El CLI genera la estructura del reporte y el prompt; el resultado lo produce el
4
+ subagente que ejecuta la auditoría (spec §10: no prometer resultado determinístico).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ from jinja2 import Environment, PackageLoader, select_autoescape
13
+
14
+ from logsayer.core.project import project_name, read_closed_hus
15
+
16
+ AUDITS_DIR = Path("docs") / "06_audits"
17
+
18
+
19
+ class AuditError(Exception):
20
+ """Error controlado de auditoría."""
21
+
22
+
23
+ def _render(template: str, **context: object) -> str:
24
+ env = Environment(
25
+ loader=PackageLoader("logsayer", "templates"),
26
+ autoescape=select_autoescape(("html", "xml")),
27
+ )
28
+ return env.get_template(template).render(**context)
29
+
30
+
31
+ def _next_report_path(root: Path, date: str) -> Path:
32
+ audits_dir = root / AUDITS_DIR
33
+ audits_dir.mkdir(parents=True, exist_ok=True)
34
+ counter = 0
35
+ while True:
36
+ suffix = "" if counter == 0 else f"-{counter}"
37
+ candidate = audits_dir / f"audit_{date}{suffix}.md"
38
+ if not candidate.exists():
39
+ return candidate
40
+ counter += 1
41
+
42
+
43
+ def last_audit(root: Path) -> Path | None:
44
+ audits_dir = root / AUDITS_DIR
45
+ if not audits_dir.is_dir():
46
+ return None
47
+ reports = [
48
+ path
49
+ for path in audits_dir.glob("audit_*.md")
50
+ if not path.name.endswith(".prompt.md")
51
+ ]
52
+ if not reports:
53
+ return None
54
+ return max(reports)
55
+
56
+
57
+ def run_audit(root: Path) -> Path:
58
+ """Genera el reporte y el prompt de auditoría para la Decidora."""
59
+ date = datetime.now().strftime("%Y-%m-%d")
60
+ report = _next_report_path(root, date)
61
+ name = project_name(root)
62
+ closed = read_closed_hus(root)
63
+ report.write_text(
64
+ _render(
65
+ "audit_report.md.j2",
66
+ date=date,
67
+ project_name=name,
68
+ closed_hus=closed,
69
+ )
70
+ )
71
+ prompt = report.with_suffix(".prompt.md")
72
+ prompt.write_text(
73
+ _render(
74
+ "audit_prompt.md.j2",
75
+ date=date,
76
+ project_name=name,
77
+ closed_hus=closed,
78
+ report_name=report.name,
79
+ )
80
+ )
81
+ return report
@@ -0,0 +1,22 @@
1
+ """Resultado de chequeos determinísticos (bots: Suk Doctor y Fremen)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class CheckResult:
10
+ """Resultado de un chequeo único con criterio objetivo (sin juicio)."""
11
+
12
+ name: str
13
+ ok: bool
14
+ detail: str
15
+
16
+
17
+ def ok(name: str, detail: str = "OK") -> CheckResult:
18
+ return CheckResult(name=name, ok=True, detail=detail)
19
+
20
+
21
+ def fail(name: str, detail: str) -> CheckResult:
22
+ return CheckResult(name=name, ok=False, detail=detail)