feat: engagement CLI for forward-deployed agency (WP-0009 T08)

Add kaizen-agentic engagement subcommands (list, show, validate, checklist,
phase, prepare, staff, quote, scrub, export-handoff) backed by engagement.py
for file-based pilot lifecycle. Tests cover staff/validate/phase/prepare and
the railiance01 pilot smoke path.
This commit is contained in:
tegwick 2026-07-16 11:20:52 +02:00
parent 600954500f
commit ad5965be91
6 changed files with 1661 additions and 7 deletions

View file

@ -24,6 +24,25 @@ from .integrations.helix import HelixCorrelationAdapter, enrich_helix_correlatio
from .metrics import MetricsStore, OptimizerStore, performance_summary_markdown
from .optimization import OptimizationLoop, MIN_SAMPLES_FOR_RECOMMENDATIONS
from .engagement_promote import promote_engagement
from .engagement import (
EngagementError,
build_prepare_bundle,
export_handoff,
find_repo_root,
list_engagement_dirs,
load_checklist,
load_engagement,
load_quote,
checklist_summary,
render_prepare_markdown,
resolve_engagement_dir,
scrub_engagement,
set_checklist_item_status,
set_phase,
staff_engagement,
validate_engagement,
VALID_PHASES,
)
from .schedule import (
ScheduleError,
default_schedule_yaml,
@ -1412,6 +1431,372 @@ def protocols_show(agent_name: str, slug: str):
click.echo(protocol_path.read_text())
@cli.group()
def engagement():
"""Forward-deployed engagement lifecycle (KAIZEN-WP-0009 / DEC-FDA-001).
Manage file-based engagements under engagements/pilots/<id>/: phase,
checklists, prepare bundles, staff from Role packages. Offline; no LLM invoke.
"""
pass
def _resolve_eng(engagement_ref: str, repo_root: Optional[str]):
root = Path(repo_root).resolve() if repo_root else find_repo_root()
try:
path = resolve_engagement_dir(engagement_ref, root)
return load_engagement(path), root
except EngagementError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
@engagement.command("list")
@click.option(
"--repo-root",
default=None,
help="Repo root containing engagements/ (default: auto-detect)",
)
@click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON")
def engagement_list(repo_root: Optional[str], as_json: bool):
"""List engagement directories under engagements/pilots/."""
root = Path(repo_root).resolve() if repo_root else find_repo_root()
dirs = list_engagement_dirs(root)
rows = []
for d in dirs:
try:
eng = load_engagement(d)
rows.append(
{
"id": eng.engagement_id,
"phase": eng.phase,
"role": eng.role_id,
"path": str(d),
}
)
except EngagementError as exc:
rows.append(
{
"id": d.name,
"phase": "?",
"role": None,
"path": str(d),
"error": str(exc),
}
)
if as_json:
click.echo(json.dumps(rows, indent=2))
return
if not rows:
click.echo(f"No engagements under {root / 'engagements'}")
return
for r in rows:
role = r.get("role") or "-"
click.echo(f" {r['id']}: phase={r['phase']} role={role}")
click.echo(f" {r['path']}")
@engagement.command("show")
@click.argument("engagement_ref")
@click.option("--repo-root", default=None, help="Repo root (default: auto-detect)")
@click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON")
def engagement_show(engagement_ref: str, repo_root: Optional[str], as_json: bool):
"""Show engagement summary from ENGAGEMENT.yaml."""
eng, _root = _resolve_eng(engagement_ref, repo_root)
payload = {
"id": eng.engagement_id,
"phase": eng.phase,
"role": eng.role_id,
"targets": eng.targets,
"path": str(eng.path),
"validation_errors": validate_engagement(eng),
}
if as_json:
click.echo(json.dumps(payload, indent=2))
return
click.echo(f"Engagement: {payload['id']}")
click.echo(f" Phase: {payload['phase']}")
click.echo(f" Role: {payload['role']}")
click.echo(f" Path: {payload['path']}")
for t in payload["targets"]:
click.echo(f" Target: {t.get('kind')} {t.get('id')}")
errs = payload["validation_errors"]
if errs:
click.echo(" Validation:")
for e in errs:
click.echo(f" - {e}")
else:
click.echo(" Validation: ok")
@engagement.command("validate")
@click.argument("engagement_ref")
@click.option("--repo-root", default=None, help="Repo root (default: auto-detect)")
def engagement_validate(engagement_ref: str, repo_root: Optional[str]):
"""Validate engagement tree structure and required files."""
eng, _root = _resolve_eng(engagement_ref, repo_root)
errs = validate_engagement(eng)
if errs:
click.echo(f"{eng.engagement_id}: {len(errs)} issue(s)")
for e in errs:
click.echo(f" - {e}")
sys.exit(1)
click.echo(f"{eng.engagement_id}: valid (phase={eng.phase})")
@engagement.command("checklist")
@click.argument("engagement_ref")
@click.option(
"--which",
type=click.Choice(["ramp_up", "ramp_down", "both"]),
default="both",
show_default=True,
)
@click.option(
"--mark",
metavar="ID=STATUS",
help="Update one item, e.g. RU-01=done (implies --which for that prefix)",
)
@click.option("--repo-root", default=None, help="Repo root (default: auto-detect)")
@click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON")
def engagement_checklist(
engagement_ref: str,
which: str,
mark: Optional[str],
repo_root: Optional[str],
as_json: bool,
):
"""Show or update ramp-up / ramp-down checklist status."""
eng, _root = _resolve_eng(engagement_ref, repo_root)
if mark:
if "=" not in mark:
click.echo("Error: --mark requires ID=STATUS (e.g. RU-01=done)", err=True)
sys.exit(1)
item_id, status = mark.split("=", 1)
item_id, status = item_id.strip(), status.strip()
which_mark = "ramp_up" if item_id.upper().startswith("RU") else "ramp_down"
path, _items = load_checklist(eng, which_mark)
if path is None:
click.echo(f"Error: no {which_mark} checklist", err=True)
sys.exit(1)
if not set_checklist_item_status(path, item_id, status):
click.echo(f"Error: item {item_id} not found in {path}", err=True)
sys.exit(1)
click.echo(f"Updated {item_id}{status} in {path}")
which_list = ["ramp_up", "ramp_down"] if which == "both" else [which]
result: dict = {"engagement_id": eng.engagement_id, "checklists": {}}
for w in which_list:
path, items = load_checklist(eng, w)
summary = checklist_summary(items)
result["checklists"][w] = {
"path": str(path) if path else None,
"summary": summary,
"items": [
{
"id": i.item_id,
"criterion": i.criterion,
"status": i.status,
"evidence": i.evidence,
"done": i.done,
}
for i in items
],
}
if as_json:
click.echo(json.dumps(result, indent=2))
return
for w, block in result["checklists"].items():
summary = block["summary"]
click.echo(
f"{w}: {summary['done']}/{summary['total']}"
+ (" ✅ complete" if summary["complete"] else "")
)
if not block["path"]:
click.echo(" (checklist file missing)")
continue
for it in block["items"]:
flag = "" if it["done"] else ""
click.echo(f" {flag} {it['id']}: {it['criterion']} [{it['status']}]")
@engagement.command("phase")
@click.argument("engagement_ref")
@click.option(
"--to",
"to_phase",
required=True,
help=f"Target phase ({', '.join(VALID_PHASES)})",
)
@click.option("--force", is_flag=True, help="Skip transition graph checks")
@click.option("--notes", default=None, help="Optional status.notes update")
@click.option("--repo-root", default=None, help="Repo root (default: auto-detect)")
def engagement_phase(
engagement_ref: str,
to_phase: str,
force: bool,
notes: Optional[str],
repo_root: Optional[str],
):
"""Transition engagement phase (updates ENGAGEMENT.yaml + agent frontmatter)."""
eng, _root = _resolve_eng(engagement_ref, repo_root)
prev = eng.phase
try:
eng = set_phase(eng, to_phase, force=force, notes=notes)
except EngagementError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
click.echo(f"{eng.engagement_id}: {prev}{eng.phase}")
@engagement.command("prepare")
@click.argument("engagement_ref")
@click.option("--repo-root", default=None, help="Repo root (default: auto-detect)")
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json"]),
default="markdown",
show_default=True,
)
def engagement_prepare(
engagement_ref: str, repo_root: Optional[str], output_format: str
):
"""Assemble session orientation bundle (agent + vault + protocols + access)."""
eng, root = _resolve_eng(engagement_ref, repo_root)
bundle = build_prepare_bundle(eng, root)
if output_format == "json":
# Drop large protocol bodies in json unless needed — keep presence flags
slim = {k: v for k, v in bundle.items() if k != "protocol_bodies"}
# Include protocol body sizes only
slim["protocol_chars"] = {
k: len(v) for k, v in (bundle.get("protocol_bodies") or {}).items()
}
click.echo(json.dumps(slim, indent=2))
return
click.echo(render_prepare_markdown(bundle))
@engagement.command("quote")
@click.argument("engagement_ref")
@click.option("--repo-root", default=None, help="Repo root (default: auto-detect)")
@click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON")
def engagement_quote(engagement_ref: str, repo_root: Optional[str], as_json: bool):
"""Show Kai quote snapshot for an engagement."""
eng, _root = _resolve_eng(engagement_ref, repo_root)
try:
quote = load_quote(eng)
except EngagementError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
if quote is None:
click.echo(f"No quote file for {eng.engagement_id}")
sys.exit(1)
if as_json:
click.echo(json.dumps(quote, indent=2))
return
spec = quote.get("spec") or {}
total = spec.get("total_kai")
click.echo(f"Quote: {eng.engagement_id}")
if total is not None:
click.echo(f" Total: {total} Kai")
for item in spec.get("line_items") or []:
if isinstance(item, dict):
click.echo(
f" - {item.get('product')}: {item.get('amount_kai')} Kai"
f" ({item.get('description', '')})"
)
for note in spec.get("notes") or []:
click.echo(f" note: {note}")
@engagement.command("staff")
@click.option(
"--id", "engagement_id", required=True, help="Engagement id (directory name)"
)
@click.option("--role", "role_id", required=True, help="Role package id under roles/")
@click.option("--client", "client_id", required=True, help="Client id")
@click.option(
"--target", "target_id", required=True, help="Primary target id (e.g. host)"
)
@click.option(
"--target-kind",
default="host",
show_default=True,
help="Target kind",
)
@click.option("--repo-root", default=None, help="Repo root (default: auto-detect)")
@click.option("--force", is_flag=True, help="Overwrite scaffold if directory exists")
def engagement_staff(
engagement_id: str,
role_id: str,
client_id: str,
target_id: str,
target_kind: str,
repo_root: Optional[str],
force: bool,
):
"""Scaffold a pilot engagement from a Role package."""
root = Path(repo_root).resolve() if repo_root else find_repo_root()
try:
eng = staff_engagement(
engagement_id=engagement_id,
role_id=role_id,
client_id=client_id,
target_id=target_id,
target_kind=target_kind,
repo_root=root,
force=force,
)
except EngagementError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
click.echo(f"Staffed engagement: {eng.engagement_id}")
click.echo(f" Phase: {eng.phase}")
click.echo(f" Path: {eng.path}")
click.echo(f" Next: kaizen-agentic engagement validate {eng.engagement_id}")
click.echo(
f" kaizen-agentic engagement phase {eng.engagement_id} --to ramp_up"
)
@engagement.command("scrub")
@click.argument("engagement_ref")
@click.option("--repo-root", default=None, help="Repo root (default: auto-detect)")
@click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON")
def engagement_scrub(engagement_ref: str, repo_root: Optional[str], as_json: bool):
"""Heuristic scan vault/reports for secrets before lesson contribution."""
eng, _root = _resolve_eng(engagement_ref, repo_root)
report = scrub_engagement(eng)
if as_json:
click.echo(json.dumps(report, indent=2))
return
click.echo(
f"Scrub {report['engagement_id']}: "
f"{report['files_scanned']} files, {len(report['hits'])} hit(s)"
)
for h in report["hits"]:
click.echo(f" {h['file']}:{h['line']} [{h['pattern']}] {h['snippet']}")
if report["clean"]:
click.echo(" ✅ no heuristic hits (still review before contribute_lesson)")
else:
click.echo(" ⚠ review hits before any Role craft contribution")
sys.exit(1)
@engagement.command("export-handoff")
@click.argument("engagement_ref")
@click.option("--repo-root", default=None, help="Repo root (default: auto-detect)")
def engagement_export_handoff(engagement_ref: str, repo_root: Optional[str]):
"""Ensure vault/handoff pack exists and copy baselines into it."""
eng, _root = _resolve_eng(engagement_ref, repo_root)
handoff = export_handoff(eng)
click.echo(f"Handoff pack: {handoff}")
@cli.group()
def schedule():
"""Prepare and validate scheduled agent runs (.kaizen/schedule.yml, ADR-005).