2026-03-15 13:20:57 +02:00
|
|
|
"""
|
|
|
|
|
Kin CLI — command-line interface for the multi-agent orchestrator.
|
|
|
|
|
Uses core.models for all data access, never raw SQL.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
2026-03-15 17:35:08 +02:00
|
|
|
import os
|
2026-03-15 13:20:57 +02:00
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import click
|
|
|
|
|
|
|
|
|
|
# Ensure project root is on sys.path
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
|
|
|
|
|
|
from core.db import init_db
|
|
|
|
|
from core import models
|
2026-03-15 13:29:01 +02:00
|
|
|
from agents.bootstrap import (
|
|
|
|
|
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
|
|
|
|
find_vault_root, scan_obsidian, format_preview, save_to_db,
|
|
|
|
|
)
|
2026-03-15 13:20:57 +02:00
|
|
|
|
|
|
|
|
DEFAULT_DB = Path.home() / ".kin" / "kin.db"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_conn(db_path: Path = DEFAULT_DB):
|
|
|
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
return init_db(db_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_json(ctx, param, value):
|
|
|
|
|
"""Click callback: parse a JSON string or return None."""
|
|
|
|
|
if value is None:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(value)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
raise click.BadParameter(f"Invalid JSON: {value}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _table(headers: list[str], rows: list[list[str]], min_width: int = 6):
|
|
|
|
|
"""Render a simple aligned text table."""
|
|
|
|
|
widths = [max(min_width, len(h)) for h in headers]
|
|
|
|
|
for row in rows:
|
|
|
|
|
for i, cell in enumerate(row):
|
|
|
|
|
if i < len(widths):
|
|
|
|
|
widths[i] = max(widths[i], len(str(cell)))
|
|
|
|
|
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
|
|
|
|
lines = [fmt.format(*headers), fmt.format(*("-" * w for w in widths))]
|
|
|
|
|
for row in rows:
|
|
|
|
|
lines.append(fmt.format(*[str(c) for c in row]))
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _auto_task_id(conn, project_id: str) -> str:
|
|
|
|
|
"""Generate next task ID like PROJ-001."""
|
|
|
|
|
prefix = project_id.upper()
|
|
|
|
|
existing = models.list_tasks(conn, project_id=project_id)
|
|
|
|
|
max_num = 0
|
|
|
|
|
for t in existing:
|
|
|
|
|
tid = t["id"]
|
|
|
|
|
if tid.startswith(prefix + "-"):
|
|
|
|
|
try:
|
|
|
|
|
num = int(tid.split("-", 1)[1])
|
|
|
|
|
max_num = max(max_num, num)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
return f"{prefix}-{max_num + 1:03d}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# Root group
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@click.group()
|
|
|
|
|
@click.option("--db", type=click.Path(), default=None, envvar="KIN_DB",
|
|
|
|
|
help="Path to kin.db (default: ~/.kin/kin.db, or $KIN_DB)")
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def cli(ctx, db):
|
|
|
|
|
"""Kin — multi-agent project orchestrator."""
|
|
|
|
|
ctx.ensure_object(dict)
|
|
|
|
|
db_path = Path(db) if db else DEFAULT_DB
|
|
|
|
|
ctx.obj["conn"] = get_conn(db_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# project
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@cli.group()
|
|
|
|
|
def project():
|
|
|
|
|
"""Manage projects."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@project.command("add")
|
|
|
|
|
@click.argument("id")
|
|
|
|
|
@click.argument("name")
|
|
|
|
|
@click.argument("path")
|
|
|
|
|
@click.option("--tech-stack", callback=_parse_json, default=None, help='JSON array, e.g. \'["vue3","nuxt"]\'')
|
|
|
|
|
@click.option("--status", default="active")
|
|
|
|
|
@click.option("--priority", type=int, default=5)
|
2026-03-15 14:39:33 +02:00
|
|
|
@click.option("--language", default="ru", help="Response language for agents (ru, en, etc.)")
|
2026-03-15 13:20:57 +02:00
|
|
|
@click.pass_context
|
2026-03-15 14:39:33 +02:00
|
|
|
def project_add(ctx, id, name, path, tech_stack, status, priority, language):
|
2026-03-15 13:20:57 +02:00
|
|
|
"""Add a new project."""
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
p = models.create_project(conn, id, name, path,
|
2026-03-15 14:39:33 +02:00
|
|
|
tech_stack=tech_stack, status=status, priority=priority,
|
|
|
|
|
language=language)
|
2026-03-15 13:20:57 +02:00
|
|
|
click.echo(f"Created project: {p['id']} ({p['name']})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@project.command("list")
|
|
|
|
|
@click.option("--status", default=None)
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def project_list(ctx, status):
|
|
|
|
|
"""List projects."""
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
projects = models.list_projects(conn, status=status)
|
|
|
|
|
if not projects:
|
|
|
|
|
click.echo("No projects found.")
|
|
|
|
|
return
|
|
|
|
|
rows = [[p["id"], p["name"], p["status"], str(p["priority"]), p["path"]]
|
|
|
|
|
for p in projects]
|
|
|
|
|
click.echo(_table(["ID", "Name", "Status", "Pri", "Path"], rows))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@project.command("show")
|
|
|
|
|
@click.argument("id")
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def project_show(ctx, id):
|
|
|
|
|
"""Show project details."""
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
p = models.get_project(conn, id)
|
|
|
|
|
if not p:
|
|
|
|
|
click.echo(f"Project '{id}' not found.", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
click.echo(f"Project: {p['id']}")
|
|
|
|
|
click.echo(f" Name: {p['name']}")
|
|
|
|
|
click.echo(f" Path: {p['path']}")
|
|
|
|
|
click.echo(f" Status: {p['status']}")
|
|
|
|
|
click.echo(f" Priority: {p['priority']}")
|
|
|
|
|
if p.get("tech_stack"):
|
|
|
|
|
click.echo(f" Tech stack: {', '.join(p['tech_stack'])}")
|
|
|
|
|
if p.get("forgejo_repo"):
|
|
|
|
|
click.echo(f" Forgejo: {p['forgejo_repo']}")
|
|
|
|
|
click.echo(f" Created: {p['created_at']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# task
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@cli.group()
|
|
|
|
|
def task():
|
|
|
|
|
"""Manage tasks."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@task.command("add")
|
|
|
|
|
@click.argument("project_id")
|
|
|
|
|
@click.argument("title")
|
|
|
|
|
@click.option("--type", "route_type", type=click.Choice(["debug", "feature", "refactor", "hotfix"]), default=None)
|
|
|
|
|
@click.option("--priority", type=int, default=5)
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def task_add(ctx, project_id, title, route_type, priority):
|
|
|
|
|
"""Add a task to a project. ID is auto-generated (PROJ-001)."""
|
|
|
|
|
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)
|
|
|
|
|
task_id = _auto_task_id(conn, project_id)
|
|
|
|
|
brief = {"route_type": route_type} if route_type else None
|
|
|
|
|
t = models.create_task(conn, task_id, project_id, title,
|
|
|
|
|
priority=priority, brief=brief)
|
|
|
|
|
click.echo(f"Created task: {t['id']} — {t['title']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@task.command("list")
|
|
|
|
|
@click.option("--project", "project_id", default=None)
|
|
|
|
|
@click.option("--status", default=None)
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def task_list(ctx, project_id, status):
|
|
|
|
|
"""List tasks."""
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
tasks = models.list_tasks(conn, project_id=project_id, status=status)
|
|
|
|
|
if not tasks:
|
|
|
|
|
click.echo("No tasks found.")
|
|
|
|
|
return
|
|
|
|
|
rows = [[t["id"], t["project_id"], t["title"][:40], t["status"],
|
|
|
|
|
str(t["priority"]), t.get("assigned_role") or "-"]
|
|
|
|
|
for t in tasks]
|
|
|
|
|
click.echo(_table(["ID", "Project", "Title", "Status", "Pri", "Role"], rows))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@task.command("show")
|
|
|
|
|
@click.argument("id")
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def task_show(ctx, id):
|
|
|
|
|
"""Show task details."""
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
t = models.get_task(conn, id)
|
|
|
|
|
if not t:
|
|
|
|
|
click.echo(f"Task '{id}' not found.", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
click.echo(f"Task: {t['id']}")
|
|
|
|
|
click.echo(f" Project: {t['project_id']}")
|
|
|
|
|
click.echo(f" Title: {t['title']}")
|
|
|
|
|
click.echo(f" Status: {t['status']}")
|
|
|
|
|
click.echo(f" Priority: {t['priority']}")
|
|
|
|
|
if t.get("assigned_role"):
|
|
|
|
|
click.echo(f" Role: {t['assigned_role']}")
|
|
|
|
|
if t.get("parent_task_id"):
|
|
|
|
|
click.echo(f" Parent: {t['parent_task_id']}")
|
|
|
|
|
if t.get("brief"):
|
|
|
|
|
click.echo(f" Brief: {json.dumps(t['brief'], ensure_ascii=False)}")
|
|
|
|
|
if t.get("spec"):
|
|
|
|
|
click.echo(f" Spec: {json.dumps(t['spec'], ensure_ascii=False)}")
|
|
|
|
|
click.echo(f" Created: {t['created_at']}")
|
|
|
|
|
click.echo(f" Updated: {t['updated_at']}")
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 17:44:16 +02:00
|
|
|
@task.command("update")
|
|
|
|
|
@click.argument("task_id")
|
|
|
|
|
@click.option("--status", type=click.Choice(
|
|
|
|
|
["pending", "in_progress", "review", "done", "blocked", "decomposed"]),
|
|
|
|
|
default=None, help="New status")
|
|
|
|
|
@click.option("--priority", type=int, default=None, help="New priority (1-10)")
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def task_update(ctx, task_id, status, priority):
|
|
|
|
|
"""Update a task's status or priority."""
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
t = models.get_task(conn, task_id)
|
|
|
|
|
if not t:
|
|
|
|
|
click.echo(f"Task '{task_id}' not found.", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
fields = {}
|
|
|
|
|
if status is not None:
|
|
|
|
|
fields["status"] = status
|
|
|
|
|
if priority is not None:
|
|
|
|
|
fields["priority"] = priority
|
|
|
|
|
if not fields:
|
|
|
|
|
click.echo("Nothing to update. Use --status or --priority.", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
updated = models.update_task(conn, task_id, **fields)
|
|
|
|
|
click.echo(f"Updated {updated['id']}: status={updated['status']}, priority={updated['priority']}")
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 13:20:57 +02:00
|
|
|
# ===========================================================================
|
|
|
|
|
# decision
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@cli.group()
|
|
|
|
|
def decision():
|
|
|
|
|
"""Manage decisions and gotchas."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@decision.command("add")
|
|
|
|
|
@click.argument("project_id")
|
|
|
|
|
@click.argument("type", type=click.Choice(["decision", "gotcha", "workaround", "rejected_approach", "convention"]))
|
|
|
|
|
@click.argument("title")
|
|
|
|
|
@click.argument("description")
|
|
|
|
|
@click.option("--category", default=None)
|
|
|
|
|
@click.option("--tags", callback=_parse_json, default=None, help='JSON array, e.g. \'["ios","css"]\'')
|
|
|
|
|
@click.option("--task-id", default=None)
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def decision_add(ctx, project_id, type, title, description, category, tags, task_id):
|
|
|
|
|
"""Record a decision, gotcha, or convention."""
|
|
|
|
|
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)
|
|
|
|
|
d = models.add_decision(conn, project_id, type, title, description,
|
|
|
|
|
category=category, tags=tags, task_id=task_id)
|
|
|
|
|
click.echo(f"Added {d['type']}: #{d['id']} — {d['title']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@decision.command("list")
|
|
|
|
|
@click.argument("project_id")
|
|
|
|
|
@click.option("--category", default=None)
|
|
|
|
|
@click.option("--tag", multiple=True, help="Filter by tag (can repeat)")
|
|
|
|
|
@click.option("--type", "types", multiple=True,
|
|
|
|
|
type=click.Choice(["decision", "gotcha", "workaround", "rejected_approach", "convention"]),
|
|
|
|
|
help="Filter by type (can repeat)")
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def decision_list(ctx, project_id, category, tag, types):
|
|
|
|
|
"""List decisions for a project."""
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
tags_list = list(tag) if tag else None
|
|
|
|
|
types_list = list(types) if types else None
|
|
|
|
|
decisions = models.get_decisions(conn, project_id, category=category,
|
|
|
|
|
tags=tags_list, types=types_list)
|
|
|
|
|
if not decisions:
|
|
|
|
|
click.echo("No decisions found.")
|
|
|
|
|
return
|
|
|
|
|
rows = [[str(d["id"]), d["type"], d["category"] or "-",
|
|
|
|
|
d["title"][:50], d["created_at"][:10]]
|
|
|
|
|
for d in decisions]
|
|
|
|
|
click.echo(_table(["#", "Type", "Category", "Title", "Date"], rows))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# module
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@cli.group()
|
|
|
|
|
def module():
|
|
|
|
|
"""Manage project modules."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@module.command("add")
|
|
|
|
|
@click.argument("project_id")
|
|
|
|
|
@click.argument("name")
|
|
|
|
|
@click.argument("type", type=click.Choice(["frontend", "backend", "shared", "infra"]))
|
|
|
|
|
@click.argument("path")
|
|
|
|
|
@click.option("--description", default=None)
|
|
|
|
|
@click.option("--owner-role", default=None)
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def module_add(ctx, project_id, name, type, path, description, owner_role):
|
|
|
|
|
"""Register a project module."""
|
|
|
|
|
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)
|
|
|
|
|
m = models.add_module(conn, project_id, name, type, path,
|
|
|
|
|
description=description, owner_role=owner_role)
|
|
|
|
|
click.echo(f"Added module: {m['name']} ({m['type']}) at {m['path']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@module.command("list")
|
|
|
|
|
@click.argument("project_id")
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def module_list(ctx, project_id):
|
|
|
|
|
"""List modules for a project."""
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
mods = models.get_modules(conn, project_id)
|
|
|
|
|
if not mods:
|
|
|
|
|
click.echo("No modules found.")
|
|
|
|
|
return
|
|
|
|
|
rows = [[m["name"], m["type"], m["path"], m.get("owner_role") or "-",
|
|
|
|
|
m.get("description") or ""]
|
|
|
|
|
for m in mods]
|
|
|
|
|
click.echo(_table(["Name", "Type", "Path", "Owner", "Description"], rows))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# status
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@cli.command("status")
|
|
|
|
|
@click.argument("project_id", required=False)
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def status(ctx, project_id):
|
|
|
|
|
"""Project status overview. Without args — all projects. With id — detailed."""
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
|
|
|
|
|
if project_id:
|
|
|
|
|
p = models.get_project(conn, project_id)
|
|
|
|
|
if not p:
|
|
|
|
|
click.echo(f"Project '{project_id}' not found.", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
tasks = models.list_tasks(conn, project_id=project_id)
|
|
|
|
|
counts = {}
|
|
|
|
|
for t in tasks:
|
|
|
|
|
counts[t["status"]] = counts.get(t["status"], 0) + 1
|
|
|
|
|
|
|
|
|
|
click.echo(f"Project: {p['id']} — {p['name']} [{p['status']}]")
|
|
|
|
|
click.echo(f" Path: {p['path']}")
|
|
|
|
|
if p.get("tech_stack"):
|
|
|
|
|
click.echo(f" Stack: {', '.join(p['tech_stack'])}")
|
|
|
|
|
click.echo(f" Tasks: {len(tasks)} total")
|
|
|
|
|
for s in ["pending", "in_progress", "review", "done", "blocked"]:
|
|
|
|
|
if counts.get(s, 0) > 0:
|
|
|
|
|
click.echo(f" {s}: {counts[s]}")
|
|
|
|
|
if tasks:
|
|
|
|
|
click.echo("")
|
|
|
|
|
rows = [[t["id"], t["title"][:40], t["status"],
|
|
|
|
|
t.get("assigned_role") or "-"]
|
|
|
|
|
for t in tasks]
|
|
|
|
|
click.echo(_table(["ID", "Title", "Status", "Role"], rows))
|
|
|
|
|
else:
|
|
|
|
|
summary = models.get_project_summary(conn)
|
|
|
|
|
if not summary:
|
|
|
|
|
click.echo("No projects.")
|
|
|
|
|
return
|
|
|
|
|
rows = [[s["id"], s["name"][:25], s["status"], str(s["priority"]),
|
|
|
|
|
str(s["total_tasks"]), str(s["done_tasks"]),
|
|
|
|
|
str(s["active_tasks"]), str(s["blocked_tasks"])]
|
|
|
|
|
for s in summary]
|
|
|
|
|
click.echo(_table(
|
|
|
|
|
["ID", "Name", "Status", "Pri", "Total", "Done", "Active", "Blocked"],
|
|
|
|
|
rows,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
# cost
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@cli.command("cost")
|
|
|
|
|
@click.option("--last", "period", default="7d", help="Period: 7d, 30d, etc.")
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def cost(ctx, period):
|
|
|
|
|
"""Show cost summary by project."""
|
|
|
|
|
# Parse period like "7d", "30d"
|
|
|
|
|
period = period.strip().lower()
|
|
|
|
|
if period.endswith("d"):
|
|
|
|
|
try:
|
|
|
|
|
days = int(period[:-1])
|
|
|
|
|
except ValueError:
|
|
|
|
|
click.echo(f"Invalid period: {period}. Use e.g. 7d, 30d.", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
days = int(period)
|
|
|
|
|
except ValueError:
|
|
|
|
|
click.echo(f"Invalid period: {period}. Use e.g. 7d, 30d.", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
costs = models.get_cost_summary(conn, days=days)
|
|
|
|
|
if not costs:
|
|
|
|
|
click.echo(f"No agent runs in the last {days} days.")
|
|
|
|
|
return
|
|
|
|
|
rows = [[c["project_id"], c["project_name"][:25], str(c["runs"]),
|
|
|
|
|
f"{c['total_tokens']:,}", f"${c['total_cost_usd']:.4f}",
|
|
|
|
|
f"{c['total_duration_seconds']}s"]
|
|
|
|
|
for c in costs]
|
|
|
|
|
click.echo(f"Cost summary (last {days} days):\n")
|
|
|
|
|
click.echo(_table(
|
|
|
|
|
["Project", "Name", "Runs", "Tokens", "Cost", "Time"],
|
|
|
|
|
rows,
|
|
|
|
|
))
|
|
|
|
|
total = sum(c["total_cost_usd"] for c in costs)
|
|
|
|
|
click.echo(f"\nTotal: ${total:.4f}")
|
|
|
|
|
|
|
|
|
|
|
Add follow-up task generation on approve
When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).
core/followup.py:
generate_followups() — collects pipeline output, runs followup agent,
parses JSON task list, creates tasks with parent_task_id linkage.
Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.
agents/prompts/followup.md — PM prompt for analyzing results and
creating actionable follow-up tasks with priority from severity.
CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
Returns {status, decision, followup_tasks: [...]}
Frontend (TaskDetail approve modal):
- Checkbox "Create follow-up tasks" (default ON)
- Loading state during generation
- Results view: list of created tasks with links to /task/:id
ProjectView: tasks show "from VDOL-001" for follow-ups.
13 new tests (followup), 125 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:02:58 +02:00
|
|
|
# ===========================================================================
|
|
|
|
|
# approve
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@cli.command("approve")
|
|
|
|
|
@click.argument("task_id")
|
|
|
|
|
@click.option("--followup", is_flag=True, help="Generate follow-up tasks from pipeline results")
|
|
|
|
|
@click.option("--decision", "decision_text", default=None, help="Record a decision with this text")
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def approve_task(ctx, task_id, followup, decision_text):
|
|
|
|
|
"""Approve a task (set status=done). Optionally generate follow-ups."""
|
2026-03-15 15:16:48 +02:00
|
|
|
from core.followup import generate_followups, resolve_pending_action
|
Add follow-up task generation on approve
When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).
core/followup.py:
generate_followups() — collects pipeline output, runs followup agent,
parses JSON task list, creates tasks with parent_task_id linkage.
Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.
agents/prompts/followup.md — PM prompt for analyzing results and
creating actionable follow-up tasks with priority from severity.
CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
Returns {status, decision, followup_tasks: [...]}
Frontend (TaskDetail approve modal):
- Checkbox "Create follow-up tasks" (default ON)
- Loading state during generation
- Results view: list of created tasks with links to /task/:id
ProjectView: tasks show "from VDOL-001" for follow-ups.
13 new tests (followup), 125 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:02:58 +02:00
|
|
|
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
task = models.get_task(conn, task_id)
|
|
|
|
|
if not task:
|
|
|
|
|
click.echo(f"Task '{task_id}' not found.", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
models.update_task(conn, task_id, status="done")
|
|
|
|
|
click.echo(f"Approved: {task_id} → done")
|
|
|
|
|
|
|
|
|
|
if decision_text:
|
|
|
|
|
models.add_decision(
|
|
|
|
|
conn, task["project_id"], "decision", decision_text, decision_text,
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
)
|
|
|
|
|
click.echo(f"Decision recorded.")
|
|
|
|
|
|
|
|
|
|
if followup:
|
|
|
|
|
click.echo("Generating follow-up tasks...")
|
2026-03-15 15:16:48 +02:00
|
|
|
result = generate_followups(conn, task_id)
|
|
|
|
|
created = result["created"]
|
|
|
|
|
pending = result["pending_actions"]
|
|
|
|
|
|
Add follow-up task generation on approve
When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).
core/followup.py:
generate_followups() — collects pipeline output, runs followup agent,
parses JSON task list, creates tasks with parent_task_id linkage.
Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.
agents/prompts/followup.md — PM prompt for analyzing results and
creating actionable follow-up tasks with priority from severity.
CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
Returns {status, decision, followup_tasks: [...]}
Frontend (TaskDetail approve modal):
- Checkbox "Create follow-up tasks" (default ON)
- Loading state during generation
- Results view: list of created tasks with links to /task/:id
ProjectView: tasks show "from VDOL-001" for follow-ups.
13 new tests (followup), 125 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:02:58 +02:00
|
|
|
if created:
|
|
|
|
|
click.echo(f"Created {len(created)} follow-up tasks:")
|
|
|
|
|
for t in created:
|
|
|
|
|
click.echo(f" {t['id']}: {t['title']} (pri {t['priority']})")
|
2026-03-15 15:16:48 +02:00
|
|
|
|
|
|
|
|
for action in pending:
|
|
|
|
|
click.echo(f"\nPermission issue: {action['description']}")
|
|
|
|
|
click.echo(" 1. Rerun with --dangerously-skip-permissions")
|
|
|
|
|
click.echo(" 2. Create task for manual fix")
|
|
|
|
|
click.echo(" 3. Skip")
|
|
|
|
|
choice_input = click.prompt("Choice", type=click.Choice(["1", "2", "3"]), default="2")
|
|
|
|
|
choice_map = {"1": "rerun", "2": "manual_task", "3": "skip"}
|
|
|
|
|
choice = choice_map[choice_input]
|
|
|
|
|
result = resolve_pending_action(conn, task_id, action, choice)
|
|
|
|
|
if choice == "rerun" and result:
|
|
|
|
|
rr = result.get("rerun_result", {})
|
|
|
|
|
if rr.get("success"):
|
|
|
|
|
click.echo(" Re-run completed successfully.")
|
|
|
|
|
else:
|
|
|
|
|
click.echo(f" Re-run failed: {rr.get('error', 'unknown')}")
|
|
|
|
|
elif choice == "manual_task" and result:
|
|
|
|
|
click.echo(f" Created: {result['id']}: {result['title']}")
|
|
|
|
|
elif choice == "skip":
|
|
|
|
|
click.echo(" Skipped.")
|
|
|
|
|
|
|
|
|
|
if not created and not pending:
|
Add follow-up task generation on approve
When approving a task, PM agent analyzes pipeline output and creates
follow-up tasks automatically (e.g. security audit → 8 fix tasks).
core/followup.py:
generate_followups() — collects pipeline output, runs followup agent,
parses JSON task list, creates tasks with parent_task_id linkage.
Handles: bare arrays, {tasks:[...]} wrappers, invalid JSON, empty.
agents/prompts/followup.md — PM prompt for analyzing results and
creating actionable follow-up tasks with priority from severity.
CLI: kin approve <task_id> [--followup] [--decision "text"]
API: POST /api/tasks/{id}/approve {create_followups: true}
Returns {status, decision, followup_tasks: [...]}
Frontend (TaskDetail approve modal):
- Checkbox "Create follow-up tasks" (default ON)
- Loading state during generation
- Results view: list of created tasks with links to /task/:id
ProjectView: tasks show "from VDOL-001" for follow-ups.
13 new tests (followup), 125 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:02:58 +02:00
|
|
|
click.echo("No follow-up tasks generated.")
|
|
|
|
|
|
|
|
|
|
|
Add context builder, agent runner, and pipeline executor
core/context_builder.py:
build_context() — assembles role-specific context from DB.
PM gets everything; debugger gets gotchas/workarounds; reviewer
gets conventions only; tester gets minimal context; security
gets security-category decisions.
format_prompt() — injects context into role templates.
agents/runner.py:
run_agent() — launches claude CLI as subprocess with role prompt.
run_pipeline() — executes multi-step pipelines sequentially,
chains output between steps, logs to agent_logs, creates/updates
pipeline records, handles failures gracefully.
agents/specialists.yaml — 8 roles with tools, permissions, context rules.
agents/prompts/pm.md — PM prompt for task decomposition.
agents/prompts/security.md — security audit prompt (OWASP, auth, secrets).
CLI: kin run <task_id> [--dry-run]
PM decomposes → shows pipeline → executes with confirmation.
31 new tests (15 context_builder, 11 runner, 5 JSON parsing).
92 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:03:32 +02:00
|
|
|
# ===========================================================================
|
|
|
|
|
# run
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@cli.command("run")
|
|
|
|
|
@click.argument("task_id")
|
|
|
|
|
@click.option("--dry-run", is_flag=True, help="Show pipeline plan without executing")
|
2026-03-15 17:35:08 +02:00
|
|
|
@click.option("--allow-write", is_flag=True, help="Allow agents to write files (skip permissions)")
|
Add context builder, agent runner, and pipeline executor
core/context_builder.py:
build_context() — assembles role-specific context from DB.
PM gets everything; debugger gets gotchas/workarounds; reviewer
gets conventions only; tester gets minimal context; security
gets security-category decisions.
format_prompt() — injects context into role templates.
agents/runner.py:
run_agent() — launches claude CLI as subprocess with role prompt.
run_pipeline() — executes multi-step pipelines sequentially,
chains output between steps, logs to agent_logs, creates/updates
pipeline records, handles failures gracefully.
agents/specialists.yaml — 8 roles with tools, permissions, context rules.
agents/prompts/pm.md — PM prompt for task decomposition.
agents/prompts/security.md — security audit prompt (OWASP, auth, secrets).
CLI: kin run <task_id> [--dry-run]
PM decomposes → shows pipeline → executes with confirmation.
31 new tests (15 context_builder, 11 runner, 5 JSON parsing).
92 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:03:32 +02:00
|
|
|
@click.pass_context
|
2026-03-15 17:35:08 +02:00
|
|
|
def run_task(ctx, task_id, dry_run, allow_write):
|
Add context builder, agent runner, and pipeline executor
core/context_builder.py:
build_context() — assembles role-specific context from DB.
PM gets everything; debugger gets gotchas/workarounds; reviewer
gets conventions only; tester gets minimal context; security
gets security-category decisions.
format_prompt() — injects context into role templates.
agents/runner.py:
run_agent() — launches claude CLI as subprocess with role prompt.
run_pipeline() — executes multi-step pipelines sequentially,
chains output between steps, logs to agent_logs, creates/updates
pipeline records, handles failures gracefully.
agents/specialists.yaml — 8 roles with tools, permissions, context rules.
agents/prompts/pm.md — PM prompt for task decomposition.
agents/prompts/security.md — security audit prompt (OWASP, auth, secrets).
CLI: kin run <task_id> [--dry-run]
PM decomposes → shows pipeline → executes with confirmation.
31 new tests (15 context_builder, 11 runner, 5 JSON parsing).
92 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:03:32 +02:00
|
|
|
"""Run a task through the agent pipeline.
|
|
|
|
|
|
|
|
|
|
PM decomposes the task into specialist steps, then the pipeline executes.
|
|
|
|
|
With --dry-run, shows the plan without running agents.
|
|
|
|
|
"""
|
|
|
|
|
from agents.runner import run_agent, run_pipeline
|
|
|
|
|
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
task = models.get_task(conn, task_id)
|
|
|
|
|
if not task:
|
|
|
|
|
click.echo(f"Task '{task_id}' not found.", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
project_id = task["project_id"]
|
2026-03-15 17:35:08 +02:00
|
|
|
is_noninteractive = os.environ.get("KIN_NONINTERACTIVE") == "1"
|
Add context builder, agent runner, and pipeline executor
core/context_builder.py:
build_context() — assembles role-specific context from DB.
PM gets everything; debugger gets gotchas/workarounds; reviewer
gets conventions only; tester gets minimal context; security
gets security-category decisions.
format_prompt() — injects context into role templates.
agents/runner.py:
run_agent() — launches claude CLI as subprocess with role prompt.
run_pipeline() — executes multi-step pipelines sequentially,
chains output between steps, logs to agent_logs, creates/updates
pipeline records, handles failures gracefully.
agents/specialists.yaml — 8 roles with tools, permissions, context rules.
agents/prompts/pm.md — PM prompt for task decomposition.
agents/prompts/security.md — security audit prompt (OWASP, auth, secrets).
CLI: kin run <task_id> [--dry-run]
PM decomposes → shows pipeline → executes with confirmation.
31 new tests (15 context_builder, 11 runner, 5 JSON parsing).
92 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:03:32 +02:00
|
|
|
click.echo(f"Task: {task['id']} — {task['title']}")
|
|
|
|
|
|
|
|
|
|
# Step 1: PM decomposes
|
|
|
|
|
click.echo("Running PM to decompose task...")
|
|
|
|
|
pm_result = run_agent(
|
|
|
|
|
conn, "pm", task_id, project_id,
|
|
|
|
|
model="sonnet", dry_run=dry_run,
|
2026-03-15 17:35:08 +02:00
|
|
|
allow_write=allow_write, noninteractive=is_noninteractive,
|
Add context builder, agent runner, and pipeline executor
core/context_builder.py:
build_context() — assembles role-specific context from DB.
PM gets everything; debugger gets gotchas/workarounds; reviewer
gets conventions only; tester gets minimal context; security
gets security-category decisions.
format_prompt() — injects context into role templates.
agents/runner.py:
run_agent() — launches claude CLI as subprocess with role prompt.
run_pipeline() — executes multi-step pipelines sequentially,
chains output between steps, logs to agent_logs, creates/updates
pipeline records, handles failures gracefully.
agents/specialists.yaml — 8 roles with tools, permissions, context rules.
agents/prompts/pm.md — PM prompt for task decomposition.
agents/prompts/security.md — security audit prompt (OWASP, auth, secrets).
CLI: kin run <task_id> [--dry-run]
PM decomposes → shows pipeline → executes with confirmation.
31 new tests (15 context_builder, 11 runner, 5 JSON parsing).
92 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:03:32 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if dry_run:
|
|
|
|
|
click.echo("\n--- PM Prompt (dry-run) ---")
|
|
|
|
|
click.echo(pm_result.get("prompt", "")[:2000])
|
|
|
|
|
click.echo("\n(Dry-run: PM would produce a pipeline JSON)")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not pm_result["success"]:
|
|
|
|
|
click.echo(f"PM failed: {pm_result.get('output', 'unknown error')}", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
# Parse PM output for pipeline
|
|
|
|
|
output = pm_result.get("output")
|
|
|
|
|
if isinstance(output, str):
|
|
|
|
|
try:
|
|
|
|
|
output = json.loads(output)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
click.echo(f"PM returned non-JSON output:\n{output[:500]}", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
if not isinstance(output, dict) or "pipeline" not in output:
|
|
|
|
|
click.echo(f"PM output missing 'pipeline' key:\n{json.dumps(output, indent=2)[:500]}", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
pipeline_steps = output["pipeline"]
|
|
|
|
|
analysis = output.get("analysis", "")
|
|
|
|
|
|
|
|
|
|
click.echo(f"\nAnalysis: {analysis}")
|
|
|
|
|
click.echo(f"Pipeline ({len(pipeline_steps)} steps):")
|
|
|
|
|
for i, step in enumerate(pipeline_steps, 1):
|
|
|
|
|
click.echo(f" {i}. {step['role']} ({step.get('model', 'sonnet')}): {step.get('brief', '')}")
|
|
|
|
|
|
2026-03-15 17:35:08 +02:00
|
|
|
if is_noninteractive:
|
|
|
|
|
click.echo("\n[non-interactive] Auto-executing pipeline...")
|
|
|
|
|
elif not click.confirm("\nExecute pipeline?"):
|
Add context builder, agent runner, and pipeline executor
core/context_builder.py:
build_context() — assembles role-specific context from DB.
PM gets everything; debugger gets gotchas/workarounds; reviewer
gets conventions only; tester gets minimal context; security
gets security-category decisions.
format_prompt() — injects context into role templates.
agents/runner.py:
run_agent() — launches claude CLI as subprocess with role prompt.
run_pipeline() — executes multi-step pipelines sequentially,
chains output between steps, logs to agent_logs, creates/updates
pipeline records, handles failures gracefully.
agents/specialists.yaml — 8 roles with tools, permissions, context rules.
agents/prompts/pm.md — PM prompt for task decomposition.
agents/prompts/security.md — security audit prompt (OWASP, auth, secrets).
CLI: kin run <task_id> [--dry-run]
PM decomposes → shows pipeline → executes with confirmation.
31 new tests (15 context_builder, 11 runner, 5 JSON parsing).
92 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:03:32 +02:00
|
|
|
click.echo("Aborted.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Step 2: Execute pipeline
|
|
|
|
|
click.echo("\nExecuting pipeline...")
|
2026-03-15 17:35:08 +02:00
|
|
|
result = run_pipeline(conn, task_id, pipeline_steps,
|
|
|
|
|
allow_write=allow_write,
|
|
|
|
|
noninteractive=is_noninteractive)
|
Add context builder, agent runner, and pipeline executor
core/context_builder.py:
build_context() — assembles role-specific context from DB.
PM gets everything; debugger gets gotchas/workarounds; reviewer
gets conventions only; tester gets minimal context; security
gets security-category decisions.
format_prompt() — injects context into role templates.
agents/runner.py:
run_agent() — launches claude CLI as subprocess with role prompt.
run_pipeline() — executes multi-step pipelines sequentially,
chains output between steps, logs to agent_logs, creates/updates
pipeline records, handles failures gracefully.
agents/specialists.yaml — 8 roles with tools, permissions, context rules.
agents/prompts/pm.md — PM prompt for task decomposition.
agents/prompts/security.md — security audit prompt (OWASP, auth, secrets).
CLI: kin run <task_id> [--dry-run]
PM decomposes → shows pipeline → executes with confirmation.
31 new tests (15 context_builder, 11 runner, 5 JSON parsing).
92 total, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:03:32 +02:00
|
|
|
|
|
|
|
|
if result["success"]:
|
|
|
|
|
click.echo(f"\nPipeline completed: {result['steps_completed']} steps")
|
|
|
|
|
else:
|
|
|
|
|
click.echo(f"\nPipeline failed at step: {result.get('error', 'unknown')}", err=True)
|
|
|
|
|
|
|
|
|
|
if result.get("total_cost_usd"):
|
|
|
|
|
click.echo(f"Cost: ${result['total_cost_usd']:.4f}")
|
|
|
|
|
if result.get("total_duration_seconds"):
|
|
|
|
|
click.echo(f"Duration: {result['total_duration_seconds']}s")
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 17:44:16 +02:00
|
|
|
# ===========================================================================
|
|
|
|
|
# audit
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@cli.command("audit")
|
|
|
|
|
@click.argument("project_id")
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def audit_backlog(ctx, project_id):
|
|
|
|
|
"""Audit pending tasks — check which are already implemented in the code."""
|
|
|
|
|
from agents.runner import run_audit
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
pending = models.list_tasks(conn, project_id=project_id, status="pending")
|
|
|
|
|
if not pending:
|
|
|
|
|
click.echo("No pending tasks to audit.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
click.echo(f"Auditing {len(pending)} pending tasks for {project_id}...")
|
2026-03-15 18:00:39 +02:00
|
|
|
# First pass: get results only (no auto_apply yet)
|
2026-03-15 17:44:16 +02:00
|
|
|
result = run_audit(conn, project_id)
|
|
|
|
|
|
|
|
|
|
if not result["success"]:
|
|
|
|
|
click.echo(f"Audit failed: {result.get('error', 'unknown')}", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
done = result.get("already_done", [])
|
|
|
|
|
still = result.get("still_pending", [])
|
|
|
|
|
unclear = result.get("unclear", [])
|
|
|
|
|
|
|
|
|
|
if done:
|
|
|
|
|
click.echo(f"\nAlready done ({len(done)}):")
|
|
|
|
|
for item in done:
|
|
|
|
|
click.echo(f" {item['id']}: {item.get('reason', '')}")
|
|
|
|
|
|
|
|
|
|
if still:
|
|
|
|
|
click.echo(f"\nStill pending ({len(still)}):")
|
|
|
|
|
for item in still:
|
|
|
|
|
click.echo(f" {item['id']}: {item.get('reason', '')}")
|
|
|
|
|
|
|
|
|
|
if unclear:
|
|
|
|
|
click.echo(f"\nUnclear ({len(unclear)}):")
|
|
|
|
|
for item in unclear:
|
|
|
|
|
click.echo(f" {item['id']}: {item.get('reason', '')}")
|
|
|
|
|
|
|
|
|
|
if result.get("cost_usd"):
|
|
|
|
|
click.echo(f"\nCost: ${result['cost_usd']:.4f}")
|
|
|
|
|
if result.get("duration_seconds"):
|
|
|
|
|
click.echo(f"Duration: {result['duration_seconds']}s")
|
|
|
|
|
|
2026-03-15 18:00:39 +02:00
|
|
|
# Apply: mark tasks as done after user confirmation
|
2026-03-15 17:44:16 +02:00
|
|
|
if done and click.confirm(f"\nMark {len(done)} tasks as done?"):
|
|
|
|
|
for item in done:
|
2026-03-15 18:00:39 +02:00
|
|
|
tid = item.get("id")
|
|
|
|
|
if tid:
|
|
|
|
|
t = models.get_task(conn, tid)
|
|
|
|
|
if t and t["project_id"] == project_id and t["status"] == "pending":
|
|
|
|
|
models.update_task(conn, tid, status="done")
|
2026-03-15 17:44:16 +02:00
|
|
|
click.echo(f"Marked {len(done)} tasks as done.")
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 13:29:01 +02:00
|
|
|
# ===========================================================================
|
|
|
|
|
# bootstrap
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
@cli.command("bootstrap")
|
|
|
|
|
@click.argument("path", type=click.Path(exists=True))
|
|
|
|
|
@click.option("--id", "project_id", required=True, help="Short project ID (e.g. vdol)")
|
|
|
|
|
@click.option("--name", required=True, help="Project display name")
|
|
|
|
|
@click.option("--vault", "vault_path", type=click.Path(), default=None,
|
|
|
|
|
help="Obsidian vault path (auto-detected if omitted)")
|
|
|
|
|
@click.option("-y", "--yes", is_flag=True, help="Skip confirmation")
|
|
|
|
|
@click.pass_context
|
|
|
|
|
def bootstrap(ctx, path, project_id, name, vault_path, yes):
|
|
|
|
|
"""Auto-detect project stack, modules, decisions and import into Kin."""
|
|
|
|
|
conn = ctx.obj["conn"]
|
|
|
|
|
project_path = Path(path).expanduser().resolve()
|
|
|
|
|
|
|
|
|
|
# Check if project already exists
|
|
|
|
|
existing = models.get_project(conn, project_id)
|
|
|
|
|
if existing:
|
|
|
|
|
click.echo(f"Project '{project_id}' already exists. Use 'kin project show {project_id}'.", err=True)
|
|
|
|
|
raise SystemExit(1)
|
|
|
|
|
|
|
|
|
|
# Detect everything
|
|
|
|
|
click.echo(f"Scanning {project_path} ...")
|
|
|
|
|
tech_stack = detect_tech_stack(project_path)
|
|
|
|
|
modules = detect_modules(project_path)
|
Fix bootstrap: deep scan, CLAUDE.md fallback, noise filtering
1. Tech stack: recursive file search (depth 3) + CLAUDE.md text fallback
when config files are on remote server (detects nodejs, postgresql, etc.)
2. Modules: scan */src/ patterns in top-level dirs (frontend/src/, backend-pg/src/)
3. Decisions: filter out unrelated sections (Jitsi, Nextcloud, Prosody, GOIP),
filter noise (commit hashes, shell commands, external service paths).
Noise filtering also applied to Obsidian decisions.
Tested on vdolipoperek: 4 tech, 5 modules, 9 clean decisions, 24 Obsidian tasks.
61 tests, all passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:37:42 +02:00
|
|
|
decisions = extract_decisions_from_claude_md(project_path, project_id, name)
|
2026-03-15 13:29:01 +02:00
|
|
|
|
|
|
|
|
# Obsidian
|
|
|
|
|
obsidian = None
|
|
|
|
|
vault_root = find_vault_root(Path(vault_path) if vault_path else None)
|
|
|
|
|
if vault_root:
|
|
|
|
|
dir_name = project_path.name
|
|
|
|
|
obsidian = scan_obsidian(vault_root, project_id, name, dir_name)
|
|
|
|
|
if not obsidian["tasks"] and not obsidian["decisions"]:
|
|
|
|
|
obsidian = None # Nothing found, don't clutter output
|
|
|
|
|
|
|
|
|
|
# Preview
|
|
|
|
|
click.echo("")
|
|
|
|
|
click.echo(format_preview(
|
|
|
|
|
project_id, name, str(project_path), tech_stack,
|
|
|
|
|
modules, decisions, obsidian,
|
|
|
|
|
))
|
|
|
|
|
click.echo("")
|
|
|
|
|
|
|
|
|
|
if not yes:
|
|
|
|
|
if not click.confirm("Save to kin.db?"):
|
|
|
|
|
click.echo("Aborted.")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
save_to_db(conn, project_id, name, str(project_path),
|
|
|
|
|
tech_stack, modules, decisions, obsidian)
|
|
|
|
|
|
|
|
|
|
# Summary
|
|
|
|
|
task_count = 0
|
|
|
|
|
dec_count = len(decisions)
|
|
|
|
|
if obsidian:
|
|
|
|
|
task_count += len(obsidian.get("tasks", []))
|
|
|
|
|
dec_count += len(obsidian.get("decisions", []))
|
|
|
|
|
|
|
|
|
|
click.echo(f"Saved: 1 project, {len(modules)} modules, "
|
|
|
|
|
f"{dec_count} decisions, {task_count} tasks.")
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 13:20:57 +02:00
|
|
|
# ===========================================================================
|
|
|
|
|
# Entry point
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
cli()
|