feat(KIN-010): implement rebuild-frontend post-pipeline hook

- scripts/rebuild-frontend.sh: builds Vue 3 frontend and restarts uvicorn API
- cli/main.py: hook group with add/list/remove/logs/setup commands;
  `hook setup` idempotently registers rebuild-frontend for a project
- agents/runner.py: call run_hooks(event="pipeline_completed") after
  successful pipeline; wrap in try/except so hook errors never block results
- tests: 3 tests for hook_setup CLI + 3 tests for pipeline→hooks integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gros Frumos 2026-03-15 19:17:42 +02:00
parent 6705b302f7
commit 01b269e2b8
6 changed files with 355 additions and 2 deletions

View file

@ -15,6 +15,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from core.db import init_db
from core import models
from core import hooks as hooks_module
from agents.bootstrap import (
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
find_vault_root, scan_obsidian, format_preview, save_to_db,
@ -720,6 +721,135 @@ def bootstrap(ctx, path, project_id, name, vault_path, yes):
f"{dec_count} decisions, {task_count} tasks.")
# ===========================================================================
# hook
# ===========================================================================
@cli.group()
def hook():
"""Manage post-pipeline hooks."""
@hook.command("add")
@click.option("--project", "project_id", required=True, help="Project ID")
@click.option("--name", required=True, help="Hook name")
@click.option("--event", required=True, help="Event: pipeline_completed, step_completed")
@click.option("--command", required=True, help="Shell command to run")
@click.option("--module-path", default=None, help="Trigger only when module path matches (fnmatch)")
@click.option("--working-dir", default=None, help="Working directory for the command")
@click.pass_context
def hook_add(ctx, project_id, name, event, command, module_path, working_dir):
"""Add a post-pipeline hook to a project."""
conn = ctx.obj["conn"]
p = models.get_project(conn, project_id)
if not p:
click.echo(f"Project '{project_id}' not found.", err=True)
raise SystemExit(1)
h = hooks_module.create_hook(
conn, project_id, name, event, command,
trigger_module_path=module_path,
working_dir=working_dir,
)
click.echo(f"Created hook: #{h['id']} {h['name']} [{h['event']}] → {h['command']}")
@hook.command("list")
@click.option("--project", "project_id", required=True, help="Project ID")
@click.pass_context
def hook_list(ctx, project_id):
"""List hooks for a project."""
conn = ctx.obj["conn"]
hs = hooks_module.get_hooks(conn, project_id, enabled_only=False)
if not hs:
click.echo("No hooks found.")
return
rows = [
[str(h["id"]), h["name"], h["event"],
h["command"][:40], h.get("trigger_module_path") or "-",
"yes" if h["enabled"] else "no"]
for h in hs
]
click.echo(_table(["ID", "Name", "Event", "Command", "Module", "Enabled"], rows))
@hook.command("remove")
@click.argument("hook_id", type=int)
@click.pass_context
def hook_remove(ctx, hook_id):
"""Remove a hook by ID."""
conn = ctx.obj["conn"]
row = conn.execute("SELECT * FROM hooks WHERE id = ?", (hook_id,)).fetchone()
if not row:
click.echo(f"Hook #{hook_id} not found.", err=True)
raise SystemExit(1)
hooks_module.delete_hook(conn, hook_id)
click.echo(f"Removed hook #{hook_id}.")
@hook.command("logs")
@click.option("--project", "project_id", required=True, help="Project ID")
@click.option("--limit", default=20, help="Number of log entries (default: 20)")
@click.pass_context
def hook_logs(ctx, project_id, limit):
"""Show recent hook execution logs for a project."""
conn = ctx.obj["conn"]
logs = hooks_module.get_hook_logs(conn, project_id=project_id, limit=limit)
if not logs:
click.echo("No hook logs found.")
return
rows = [
[str(l["hook_id"]), l.get("task_id") or "-",
"ok" if l["success"] else "fail",
str(l["exit_code"]),
f"{l['duration_seconds']:.1f}s",
l["created_at"][:19]]
for l in logs
]
click.echo(_table(["Hook", "Task", "Result", "Exit", "Duration", "Time"], rows))
@hook.command("setup")
@click.option("--project", "project_id", required=True, help="Project ID")
@click.option("--scripts-dir", default=None,
help="Directory with hook scripts (default: <kin_root>/scripts)")
@click.pass_context
def hook_setup(ctx, project_id, scripts_dir):
"""Register standard hooks for a project.
Currently registers: rebuild-frontend (fires on web/frontend/* changes).
Idempotent skips hooks that already exist.
"""
conn = ctx.obj["conn"]
p = models.get_project(conn, project_id)
if not p:
click.echo(f"Project '{project_id}' not found.", err=True)
raise SystemExit(1)
if scripts_dir is None:
scripts_dir = str(Path(__file__).parent.parent / "scripts")
existing_names = {h["name"] for h in hooks_module.get_hooks(conn, project_id, enabled_only=False)}
created = []
if "rebuild-frontend" not in existing_names:
rebuild_cmd = str(Path(scripts_dir) / "rebuild-frontend.sh")
hooks_module.create_hook(
conn, project_id,
name="rebuild-frontend",
event="pipeline_completed",
command=rebuild_cmd,
trigger_module_path="web/frontend/*",
working_dir=p.get("path"),
timeout_seconds=300,
)
created.append("rebuild-frontend")
else:
click.echo("Hook 'rebuild-frontend' already exists, skipping.")
if created:
click.echo(f"Registered hooks: {', '.join(created)}")
# ===========================================================================
# Entry point
# ===========================================================================