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.
- logsayer/__init__.py +3 -0
- logsayer/__main__.py +4 -0
- logsayer/adapters/__init__.py +10 -0
- logsayer/adapters/base.py +21 -0
- logsayer/adapters/claude.py +22 -0
- logsayer/adapters/generate.py +39 -0
- logsayer/adapters/opencode.py +22 -0
- logsayer/adapters/registry.py +38 -0
- logsayer/cli.py +311 -0
- logsayer/config.py +62 -0
- logsayer/core/__init__.py +1 -0
- logsayer/core/audit.py +81 -0
- logsayer/core/checks.py +22 -0
- logsayer/core/fremen.py +90 -0
- logsayer/core/logbook.py +116 -0
- logsayer/core/paths.py +30 -0
- logsayer/core/project.py +84 -0
- logsayer/core/specs.py +39 -0
- logsayer/core/suk.py +155 -0
- logsayer/scaffold.py +104 -0
- logsayer/templates/00_index.md.j2 +4 -0
- logsayer/templates/AGENTS.md.j2 +29 -0
- logsayer/templates/adapters/claude/mentat.md.j2 +16 -0
- logsayer/templates/adapters/claude/navigator.md.j2 +16 -0
- logsayer/templates/adapters/claude/reverend-mother.md.j2 +17 -0
- logsayer/templates/adapters/claude/truthsayer.md.j2 +17 -0
- logsayer/templates/adapters/opencode/mentat.md.j2 +37 -0
- logsayer/templates/adapters/opencode/navigator.md.j2 +31 -0
- logsayer/templates/adapters/opencode/reverend-mother.md.j2 +35 -0
- logsayer/templates/adapters/opencode/truthsayer.md.j2 +35 -0
- logsayer/templates/audit_prompt.md.j2 +11 -0
- logsayer/templates/audit_report.md.j2 +20 -0
- logsayer/templates/hu.md.j2 +11 -0
- logsayer/templates/logsayer.toml.j2 +4 -0
- logsayer/templates/project_state.md.j2 +17 -0
- logsayer-0.6.0.dist-info/METADATA +156 -0
- logsayer-0.6.0.dist-info/RECORD +40 -0
- logsayer-0.6.0.dist-info/WHEEL +4 -0
- logsayer-0.6.0.dist-info/entry_points.txt +2 -0
- logsayer-0.6.0.dist-info/licenses/LICENSE +21 -0
logsayer/__init__.py
ADDED
logsayer/__main__.py
ADDED
|
@@ -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
|
logsayer/core/checks.py
ADDED
|
@@ -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)
|