docs(flows): DoD obligatorio con user-facing surface + abrir issues 0100-0103 (taxonomia, frontmatter migration, dev_console, work dashboard)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,28 @@
|
||||
"""Invoca `claude -p` via subprocess y devuelve la respuesta como string."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
def _resolve_claude_bin() -> str | None:
|
||||
"""Localiza claude CLI: PATH first, luego rutas convencionales."""
|
||||
p = shutil.which("claude")
|
||||
if p:
|
||||
return p
|
||||
# Fallback paths comunes (WSL subsession sin .profile cargado, etc).
|
||||
home = os.path.expanduser("~")
|
||||
candidates = [
|
||||
f"{home}/.local/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/opt/homebrew/bin/claude",
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.isfile(c) and os.access(c, os.X_OK):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def claude_cli_prompt(
|
||||
prompt: str,
|
||||
timeout_s: int = 60,
|
||||
@@ -24,16 +43,18 @@ def claude_cli_prompt(
|
||||
Respuesta de Claude como texto (stdout), truncada a max_chars_response.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Si `claude` no esta en PATH.
|
||||
FileNotFoundError: Si `claude` no esta en PATH ni rutas convencionales.
|
||||
RuntimeError: Si exit code != 0 (incluye primeros 500 chars de stderr).
|
||||
subprocess.TimeoutExpired: Si la llamada supera timeout_s segundos.
|
||||
"""
|
||||
if shutil.which("claude") is None:
|
||||
claude_bin = _resolve_claude_bin()
|
||||
if claude_bin is None:
|
||||
raise FileNotFoundError(
|
||||
"'claude' CLI no encontrado en PATH. Instala Claude Code."
|
||||
"'claude' CLI no encontrado en PATH ni rutas convencionales "
|
||||
"(~/.local/bin, /usr/local/bin, /opt/homebrew/bin). Instala Claude Code."
|
||||
)
|
||||
|
||||
cmd = ["claude", "-p", prompt]
|
||||
cmd = [claude_bin, "-p", prompt]
|
||||
if model:
|
||||
cmd.extend(["--model", model])
|
||||
if extra_args:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: codegen_app_modules
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "generate(app_md: Path, modules_root: Path, app_name: str, out_path: Path) -> int"
|
||||
description: "Reads app.md uses_modules + modules/<name>/module.md frontmatters, emits <app>_modules_generated.cpp with fn::app_modules_array[] + fn::app_modules_count. CMake hook for add_imgui_app. Pure YAML parsing, no registry.db dep."
|
||||
tags: [codegen, modules, cmake, cpp, build]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports:
|
||||
- yaml
|
||||
example: |
|
||||
python python/functions/infra/codegen_app_modules.py \
|
||||
--app-md apps/data_factory/app.md \
|
||||
--modules-root modules \
|
||||
--app-name data_factory \
|
||||
--out cpp/build/apps/data_factory/data_factory_modules_generated.cpp
|
||||
file_path: "python/functions/infra/codegen_app_modules.py"
|
||||
params:
|
||||
- name: app_md
|
||||
desc: "Path absoluto al app.md de la app consumidora. Lee uses_modules del frontmatter YAML."
|
||||
- name: modules_root
|
||||
desc: "Raiz del directorio modules/. Cada modulo es modules/<name>/module.md."
|
||||
- name: app_name
|
||||
desc: "Nombre de la app (solo para el comment-header del .cpp generado)."
|
||||
- name: out_path
|
||||
desc: "Path donde escribir el .cpp generado. Idempotente: skip si contenido coincide."
|
||||
output: "Exit code: 0 si OK, 2 si OK pero algun modulo declarado no existe (warning), >0 si error."
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
Generar el .cpp para `data_factory`:
|
||||
|
||||
```bash
|
||||
python python/functions/infra/codegen_app_modules.py \
|
||||
--app-md apps/data_factory/app.md \
|
||||
--modules-root modules \
|
||||
--app-name data_factory \
|
||||
--out /tmp/data_factory_modules_generated.cpp
|
||||
```
|
||||
|
||||
Si `data_factory/app.md` declara `uses_modules: [data_table_cpp]`, el .cpp generado es:
|
||||
|
||||
```cpp
|
||||
// Auto-generated by codegen_app_modules.py — do not edit.
|
||||
// App: data_factory
|
||||
// Source of truth: apps/data_factory/app.md (uses_modules)
|
||||
|
||||
#include "app_modules.h"
|
||||
|
||||
namespace fn {
|
||||
const ModuleInfo app_modules_array[] = {
|
||||
{ "data_table", "1.4.0", "Reusable C++ ImGui module..." },
|
||||
};
|
||||
const unsigned long app_modules_count = 1;
|
||||
} // namespace fn
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
CMake hook automatico — la macro `add_imgui_app` la invoca al configurar el build. Apps no la llaman manualmente. Manual override: solo si quieres regenerar fuera del flujo cmake (debugging).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Resuelve `<name>_cpp` strippeando el sufijo `_cpp/_py/_ts/_bash/_go`. Mismo patron que `GenerateModuleID`.
|
||||
- Si un modulo declarado en `uses_modules` no existe, emite warning a stderr y EXIT=2 (no falla el build).
|
||||
- Idempotente: solo reescribe si el contenido cambia. Evita rebuilds innecesarios cuando los modulos no cambiaron.
|
||||
- Requiere `pyyaml`. Disponible en `python/.venv` del registry.
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Generate <app>_modules_generated.cpp from app.md uses_modules + modules/*/module.md.
|
||||
|
||||
Stand-alone — no dependencies beyond PyYAML. Invoked from CMake at configure time.
|
||||
Reads YAML frontmatter directly (no registry.db dependency, no Go binary).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _read_frontmatter(md_path: Path) -> dict:
|
||||
if not md_path.exists():
|
||||
return {}
|
||||
text = md_path.read_text(encoding="utf-8")
|
||||
if not text.startswith("---\n") and not text.startswith("---\r\n"):
|
||||
return {}
|
||||
end = text.find("\n---", 4)
|
||||
if end < 0:
|
||||
return {}
|
||||
raw = text[4:end]
|
||||
try:
|
||||
return yaml.safe_load(raw) or {}
|
||||
except yaml.YAMLError:
|
||||
return {}
|
||||
|
||||
|
||||
def _escape_c_string(s: str) -> str:
|
||||
out = []
|
||||
for ch in s or "":
|
||||
if ch == "\\":
|
||||
out.append("\\\\")
|
||||
elif ch == '"':
|
||||
out.append('\\"')
|
||||
elif ch == "\n":
|
||||
out.append("\\n")
|
||||
elif ch == "\r":
|
||||
out.append("\\r")
|
||||
elif ch == "\t":
|
||||
out.append("\\t")
|
||||
elif ord(ch) < 32:
|
||||
out.append(f"\\x{ord(ch):02x}")
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _resolve_module(modules_root: Path, mod_id: str) -> Optional[dict]:
|
||||
"""mod_id is e.g. `data_table_cpp`. Lookup module.md by name (strip _<lang>)."""
|
||||
name = mod_id
|
||||
for suffix in ("_cpp", "_py", "_ts", "_bash", "_go"):
|
||||
if name.endswith(suffix):
|
||||
name = name[: -len(suffix)]
|
||||
break
|
||||
md = modules_root / name / "module.md"
|
||||
fm = _read_frontmatter(md)
|
||||
if not fm:
|
||||
return None
|
||||
return {
|
||||
"name": fm.get("name", name),
|
||||
"version": fm.get("version", "0.0.0"),
|
||||
"description": fm.get("description", ""),
|
||||
}
|
||||
|
||||
|
||||
def generate(app_md: Path, modules_root: Path, app_name: str, out_path: Path) -> int:
|
||||
fm = _read_frontmatter(app_md)
|
||||
uses_modules = fm.get("uses_modules") or []
|
||||
if not isinstance(uses_modules, list):
|
||||
uses_modules = []
|
||||
|
||||
entries: list[dict] = []
|
||||
missing: list[str] = []
|
||||
for mid in uses_modules:
|
||||
info = _resolve_module(modules_root, str(mid))
|
||||
if info is None:
|
||||
missing.append(str(mid))
|
||||
continue
|
||||
entries.append(info)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(f"// Auto-generated by codegen_app_modules.py — do not edit.")
|
||||
lines.append(f"// App: {app_name}")
|
||||
lines.append(f"// Source of truth: {app_md.as_posix()} (uses_modules)")
|
||||
lines.append("")
|
||||
lines.append('#include "app_modules.h"')
|
||||
lines.append("")
|
||||
lines.append("namespace fn {")
|
||||
if entries:
|
||||
lines.append("const ModuleInfo app_modules_array[] = {")
|
||||
for e in entries:
|
||||
lines.append(
|
||||
' { "%s", "%s", "%s" },'
|
||||
% (
|
||||
_escape_c_string(e["name"]),
|
||||
_escape_c_string(e["version"]),
|
||||
_escape_c_string(e["description"]),
|
||||
)
|
||||
)
|
||||
lines.append("};")
|
||||
lines.append(f"const unsigned long app_modules_count = {len(entries)};")
|
||||
else:
|
||||
lines.append("const ModuleInfo app_modules_array[1] = { { nullptr, nullptr, nullptr } };")
|
||||
lines.append("const unsigned long app_modules_count = 0;")
|
||||
lines.append("} // namespace fn")
|
||||
lines.append("")
|
||||
|
||||
new_content = "\n".join(lines)
|
||||
|
||||
# Idempotent: skip rewrite when content matches.
|
||||
if out_path.exists() and out_path.read_text(encoding="utf-8") == new_content:
|
||||
return 0 if not missing else 2
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(new_content, encoding="utf-8")
|
||||
|
||||
if missing:
|
||||
sys.stderr.write(
|
||||
f"codegen_app_modules: WARNING — module(s) not found: {', '.join(missing)} "
|
||||
f"(app {app_name})\n"
|
||||
)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Generate <app>_modules_generated.cpp from app.md")
|
||||
ap.add_argument("--app-md", required=True, help="Path to app.md")
|
||||
ap.add_argument("--modules-root", required=True, help="Path to modules/ root")
|
||||
ap.add_argument("--app-name", required=True, help="App name (for comment header)")
|
||||
ap.add_argument("--out", required=True, help="Output path for generated .cpp")
|
||||
args = ap.parse_args()
|
||||
|
||||
rc = generate(
|
||||
app_md=Path(args.app_md),
|
||||
modules_root=Path(args.modules_root),
|
||||
app_name=args.app_name,
|
||||
out_path=Path(args.out),
|
||||
)
|
||||
return 0 if rc in (0, 2) else rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: export_hub_manifest
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "export_hub_manifest(out_path: str, *, registry_root: str | None = None) -> dict"
|
||||
description: "Genera el TSV sidecar para app_hub_launcher: consulta registry.db por todas las apps cpp/imgui, lee su app.md para extraer nombre, descripcion y accent_hex, y escribe un archivo TSV con cabecera a out_path. Retorna {ok, count, out_path}."
|
||||
tags: [hub, launcher, manifest, suite, cpp-windows]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [sqlite3, yaml, pathlib]
|
||||
params:
|
||||
- name: out_path
|
||||
desc: "Ruta de destino del archivo TSV. Puede ser absoluta o relativa al cwd. El directorio padre se crea si no existe."
|
||||
- name: registry_root
|
||||
desc: "Raiz del fn_registry. Si None, usa la variable de entorno FN_REGISTRY_ROOT o /home/lucas/fn_registry como fallback."
|
||||
output: "Dict {ok: True, count: N, out_path: str} con la ruta absoluta del TSV escrito y el numero de apps incluidas."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/export_hub_manifest.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```bash
|
||||
# Uso directo con fn run (la salida JSON se imprime en stdout)
|
||||
./fn run export_hub_manifest_py_infra /mnt/c/Users/lucas/Desktop/apps/app_hub_launcher/local_files/hub_manifest.tsv
|
||||
```
|
||||
|
||||
```python
|
||||
# Desde un heredoc o pipeline Python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import export_hub_manifest
|
||||
|
||||
result = export_hub_manifest(
|
||||
"/mnt/c/Users/lucas/Desktop/apps/app_hub_launcher/local_files/hub_manifest.tsv"
|
||||
)
|
||||
print(result)
|
||||
# {'ok': True, 'count': 12, 'out_path': '/mnt/c/Users/lucas/Desktop/apps/app_hub_launcher/local_files/hub_manifest.tsv'}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Ver el contenido del TSV generado
|
||||
head -5 /mnt/c/Users/lucas/Desktop/apps/app_hub_launcher/local_files/hub_manifest.tsv
|
||||
# name display_name description accent_hex
|
||||
# chart_demo Chart Demo Demo ImGui de primitivos viz... #0ea5e9
|
||||
# dag_engine_ui Dag Engine Ui Motor de DAGs con frontend... #f59e0b
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de desplegar `app_hub_launcher` a Windows: genera el `hub_manifest.tsv` que el hub lee al arrancar para listar y colorear los botones de cada app. El hub en runtime no tiene acceso a `registry.db` ni a los `app.md` del WSL, por lo que necesita este sidecar. Ejecutar tras añadir o modificar una app C++ imgui en el registry.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **PyYAML en el venv**: requiere `yaml` disponible en `python/.venv`. Ya instalado por defecto. Si falta: `cd python && uv pip install pyyaml`.
|
||||
- **app.md faltante no aborta**: si un `app.md` no existe o tiene frontmatter malformado, la app sigue apareciendo en el TSV con `description` vacía y accent `#64748b` (slate). Se imprime un WARN a stderr.
|
||||
- **Filtro estricto `lang='cpp' AND framework='imgui'`**: solo apps C++ con el shell `fn::run_app`. Apps Python, Bash o C++ sin imgui quedan excluidas. Correcto para el hub.
|
||||
- **La ruta `dir_path` en registry.db es relativa a la raiz del registry**: la funcion la combina con `registry_root` para construir el path absoluto al `app.md`. Si una app tiene `dir_path` incorrecto en su `app.md`, el WARN indicara cual falló.
|
||||
- **TSV UTF-8**: el hub debe abrir el archivo con encoding UTF-8. Tabs y saltos de linea en los campos se limpian automaticamente (reemplazados por espacio).
|
||||
- **`display_name` es generado, no leido**: se deriva del `name` de la app convirtiendo snake_case a Title Case. No se puede personalizar desde el `app.md` en esta version.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
*(sin cambios desde v1.0.0)*
|
||||
@@ -0,0 +1,142 @@
|
||||
"""export_hub_manifest — genera el TSV sidecar para app_hub_launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _read_frontmatter(md_path: Path) -> dict[str, Any]:
|
||||
"""Parse YAML frontmatter from a .md file. Returns {} on any error."""
|
||||
try:
|
||||
import yaml # PyYAML — available in python/.venv
|
||||
|
||||
text = md_path.read_text(encoding="utf-8")
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
# Find the closing ---
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return {}
|
||||
yaml_block = text[3:end].strip()
|
||||
data = yaml.safe_load(yaml_block)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as exc:
|
||||
print(f"[export_hub_manifest] WARN: could not parse {md_path}: {exc}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
def _snake_to_display(name: str) -> str:
|
||||
"""Convert snake_case name to Title Case With Spaces.
|
||||
|
||||
Examples:
|
||||
graph_explorer -> Graph Explorer
|
||||
dag_engine_ui -> Dag Engine Ui
|
||||
app_hub_launcher -> App Hub Launcher
|
||||
"""
|
||||
return " ".join(part.capitalize() for part in name.split("_"))
|
||||
|
||||
|
||||
def export_hub_manifest(out_path: str, *, registry_root: str | None = None) -> dict:
|
||||
"""Generate TSV sidecar manifest for app_hub_launcher.
|
||||
|
||||
Queries registry.db for all cpp/imgui apps, reads their app.md
|
||||
frontmatter to extract name, description and accent color, then
|
||||
writes a UTF-8 TSV to out_path.
|
||||
|
||||
Args:
|
||||
out_path: Destination path for the TSV manifest file.
|
||||
registry_root: Path to the fn_registry root directory.
|
||||
Defaults to FN_REGISTRY_ROOT env var or /home/lucas/fn_registry.
|
||||
|
||||
Returns:
|
||||
{"ok": True, "count": N, "out_path": "<abs_path>"}
|
||||
"""
|
||||
root = Path(
|
||||
registry_root
|
||||
or os.environ.get("FN_REGISTRY_ROOT", "/home/lucas/fn_registry")
|
||||
).resolve()
|
||||
|
||||
db_path = root / "registry.db"
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"registry.db not found at {db_path}")
|
||||
|
||||
con = sqlite3.connect(str(db_path))
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = con.execute(
|
||||
"SELECT id, name, dir_path FROM apps WHERE lang='cpp' AND framework='imgui' ORDER BY name"
|
||||
).fetchall()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
DEFAULT_ACCENT = "#64748b"
|
||||
TSV_HEADER = "name\tdisplay_name\tdescription\taccent_hex\n"
|
||||
|
||||
lines: list[str] = [TSV_HEADER]
|
||||
count = 0
|
||||
|
||||
for row in rows:
|
||||
app_name: str = row["name"]
|
||||
dir_path: str = row["dir_path"]
|
||||
|
||||
# Derive defaults in case app.md is missing / malformed
|
||||
display_name = _snake_to_display(app_name)
|
||||
description = ""
|
||||
accent_hex = DEFAULT_ACCENT
|
||||
|
||||
md_path = root / dir_path / "app.md"
|
||||
if md_path.exists():
|
||||
fm = _read_frontmatter(md_path)
|
||||
if fm:
|
||||
description = fm.get("description", "") or ""
|
||||
icon_block = fm.get("icon")
|
||||
if isinstance(icon_block, dict):
|
||||
accent_hex = icon_block.get("accent", DEFAULT_ACCENT) or DEFAULT_ACCENT
|
||||
else:
|
||||
print(
|
||||
f"[export_hub_manifest] WARN: empty/malformed frontmatter in {md_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"[export_hub_manifest] WARN: app.md missing for {app_name} at {md_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Sanitize: TSV values must not contain tabs or newlines
|
||||
def clean(s: str) -> str:
|
||||
return s.replace("\t", " ").replace("\n", " ").replace("\r", "")
|
||||
|
||||
lines.append(
|
||||
f"{clean(app_name)}\t{clean(display_name)}\t{clean(description)}\t{clean(accent_hex)}\n"
|
||||
)
|
||||
count += 1
|
||||
|
||||
out = Path(out_path).resolve()
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
return {"ok": True, "count": count, "out_path": str(out)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import json
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Export hub manifest TSV for app_hub_launcher."
|
||||
)
|
||||
parser.add_argument("out_path", help="Destination .tsv file path")
|
||||
parser.add_argument(
|
||||
"--registry-root",
|
||||
default=None,
|
||||
help="Path to fn_registry root (default: FN_REGISTRY_ROOT env or /home/lucas/fn_registry)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
result = export_hub_manifest(args.out_path, registry_root=args.registry_root)
|
||||
print(json.dumps(result, indent=2))
|
||||
Reference in New Issue
Block a user