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:
parent
600954500f
commit
ad5965be91
6 changed files with 1661 additions and 7 deletions
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue