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).

View file

@ -0,0 +1,869 @@
"""Forward-deployed engagement lifecycle helpers (KAIZEN-WP-0009 / DEC-FDA-001).
File-based Phase 1: ENGAGEMENT.yaml under engagements/pilots/<id>/ is the source
of truth. This module loads, validates, prepares session bundles, and transitions
phases it does not invoke LLMs or touch production hosts.
"""
from __future__ import annotations
import re
import shutil
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Any, Dict, List, Optional, Pattern, Sequence, Tuple
import yaml
ENGAGEMENT_FILENAME = "ENGAGEMENT.yaml"
DEFAULT_PILOTS_DIR = Path("engagements") / "pilots"
DEFAULT_ROLES_DIR = Path("roles")
VALID_PHASES = (
"requested",
"quoting",
"funded",
"staffing",
"ramp_up",
"operating",
"renewing",
"ramp_down",
"closed",
"cancelled",
)
# Allowed forward transitions (plus renewing ↔ operating, and cancelled from most)
PHASE_TRANSITIONS: Dict[str, Tuple[str, ...]] = {
"requested": ("quoting", "cancelled"),
"quoting": ("funded", "requested", "cancelled"),
"funded": ("staffing", "cancelled"),
"staffing": ("ramp_up", "cancelled"),
"ramp_up": ("operating", "cancelled"),
"operating": ("renewing", "ramp_down", "cancelled"),
"renewing": ("operating", "ramp_down", "cancelled"),
"ramp_down": ("closed", "cancelled"),
"closed": (),
"cancelled": (),
}
# Sensitive patterns for scrub (heuristic; not a security scanner)
_SCRUB_PATTERNS: List[Tuple[str, Pattern]] = [
("private_key_block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("aws_access_key", re.compile(r"AKIA[0-9A-Z]{16}")),
("bearer_token", re.compile(r"(?i)bearer\s+[a-z0-9\-._~+/]+=*")),
(
"ipv4_privateish",
re.compile(r"\b(?:10|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}\b"),
),
("password_assignment", re.compile(r"(?i)(password|passwd|secret)\s*[:=]\s*\S+")),
]
class EngagementError(Exception):
"""Raised when an engagement cannot be loaded or operated on."""
@dataclass
class ChecklistItem:
item_id: str
criterion: str
status: str
evidence: str
@property
def done(self) -> bool:
return self.status.strip().lower() in ("done", "complete", "yes", "ok")
@dataclass
class Engagement:
"""Parsed engagement record plus filesystem paths."""
path: Path
data: Dict[str, Any]
source_path: Path
@property
def engagement_id(self) -> str:
meta = self.data.get("metadata") or {}
return str(meta.get("id") or self.path.name)
@property
def phase(self) -> str:
status = self.data.get("status") or {}
return str(status.get("phase") or "requested")
@property
def role_id(self) -> Optional[str]:
spec = self.data.get("spec") or {}
role = spec.get("role") or {}
rid = role.get("id")
return str(rid) if rid else None
@property
def targets(self) -> List[Dict[str, Any]]:
spec = self.data.get("spec") or {}
targets = spec.get("targets") or []
return list(targets) if isinstance(targets, list) else []
def rel(self, *parts: str) -> Path:
return self.path.joinpath(*parts)
def agent_definition_path(self) -> Optional[Path]:
spec = self.data.get("spec") or {}
ad = spec.get("agent_definition") or {}
rel = ad.get("path")
if not rel:
return None
p = self.path / rel
return p if p.exists() else p
def vault_memory_path(self) -> Path:
spec = self.data.get("spec") or {}
vault = spec.get("vault") or {}
rel = vault.get("memory") or "vault/memory.md"
return self.path / rel
def checklist_path(self, which: str) -> Optional[Path]:
spec = self.data.get("spec") or {}
checks = spec.get("checklists") or {}
key = "ramp_up" if which in ("ramp_up", "ramp-up", "up") else "ramp_down"
if which in ("ramp_down", "ramp-down", "down"):
key = "ramp_down"
rel = checks.get(key)
if not rel:
default = (
"checklists/ramp-up-status.md"
if key == "ramp_up"
else "checklists/ramp-down-status.md"
)
rel = default
p = self.path / rel
return p if p.exists() else None
def quote_path(self) -> Optional[Path]:
spec = self.data.get("spec") or {}
commercial = spec.get("commercial") or {}
rel = commercial.get("quote_ref") or "commercial/quote.yaml"
p = self.path / rel
return p if p.exists() else None
def ledger_path(self) -> Path:
spec = self.data.get("spec") or {}
commercial = spec.get("commercial") or {}
rel = commercial.get("ledger_ref") or "commercial/ledger.jsonl"
return self.path / rel
def today_iso() -> str:
return date.today().isoformat()
def find_repo_root(start: Optional[Path] = None) -> Path:
"""Walk parents for engagements/ or roles/ or .git."""
cur = (start or Path.cwd()).resolve()
for candidate in [cur, *cur.parents]:
if (candidate / "engagements").is_dir() or (candidate / "roles").is_dir():
return candidate
if (candidate / ".git").exists() and (
(candidate / "pyproject.toml").exists()
or (candidate / "workplans").is_dir()
):
return candidate
return cur
def list_engagement_dirs(repo_root: Path) -> List[Path]:
"""Discover engagement directories (pilots first, then other trees)."""
found: List[Path] = []
pilots = repo_root / DEFAULT_PILOTS_DIR
if pilots.is_dir():
for child in sorted(pilots.iterdir()):
if child.is_dir() and (child / ENGAGEMENT_FILENAME).exists():
found.append(child)
engagements = repo_root / "engagements"
if engagements.is_dir():
for child in sorted(engagements.iterdir()):
if child.name == "pilots":
continue
if child.is_dir() and (child / ENGAGEMENT_FILENAME).exists():
found.append(child)
return found
def resolve_engagement_dir(
engagement_ref: str, repo_root: Optional[Path] = None
) -> Path:
"""Resolve an engagement id or path to its directory."""
root = (repo_root or find_repo_root()).resolve()
ref = Path(engagement_ref)
if ref.is_dir() and (ref / ENGAGEMENT_FILENAME).exists():
return ref.resolve()
if ref.is_file() and ref.name == ENGAGEMENT_FILENAME:
return ref.parent.resolve()
# id under pilots/
pilot = root / DEFAULT_PILOTS_DIR / engagement_ref
if (pilot / ENGAGEMENT_FILENAME).exists():
return pilot.resolve()
# bare engagements/<id>
bare = root / "engagements" / engagement_ref
if (bare / ENGAGEMENT_FILENAME).exists():
return bare.resolve()
# search by metadata id
for d in list_engagement_dirs(root):
try:
eng = load_engagement(d)
except EngagementError:
continue
if eng.engagement_id == engagement_ref or d.name == engagement_ref:
return d
raise EngagementError(
f"Engagement not found: {engagement_ref!r} "
f"(looked under {root / DEFAULT_PILOTS_DIR})"
)
def load_engagement(path: Path) -> Engagement:
"""Load ENGAGEMENT.yaml from a directory (or path to the file)."""
path = Path(path)
if path.is_file():
source = path
base = path.parent
else:
source = path / ENGAGEMENT_FILENAME
base = path
if not source.exists():
raise EngagementError(f"Missing {ENGAGEMENT_FILENAME}: {source}")
try:
data = yaml.safe_load(source.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise EngagementError(f"Invalid YAML in {source}: {exc}") from exc
if not isinstance(data, dict):
raise EngagementError(f"{source} must contain a mapping")
if data.get("kind") and data.get("kind") != "Engagement":
raise EngagementError(
f"{source}: kind must be Engagement (got {data.get('kind')})"
)
return Engagement(path=base.resolve(), data=data, source_path=source.resolve())
def save_engagement(eng: Engagement) -> None:
"""Write ENGAGEMENT.yaml back (preserves structure via dump of data)."""
eng.source_path.write_text(
yaml.safe_dump(eng.data, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
def parse_checklist_markdown(text: str) -> List[ChecklistItem]:
"""Parse checklist tables with columns ID | Criterion | Status | Evidence."""
items: List[ChecklistItem] = []
for line in text.splitlines():
line = line.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 4:
continue
item_id, criterion, status, evidence = cells[0], cells[1], cells[2], cells[3]
if item_id.lower() in ("id", "---") or set(item_id) <= {"-", ":"}:
continue
if not re.match(r"^[A-Z]{1,3}-\d+", item_id):
continue
items.append(
ChecklistItem(
item_id=item_id,
criterion=criterion,
status=status,
evidence=evidence,
)
)
return items
def load_checklist(
eng: Engagement, which: str
) -> Tuple[Optional[Path], List[ChecklistItem]]:
path = eng.checklist_path(which)
if path is None:
return None, []
items = parse_checklist_markdown(path.read_text(encoding="utf-8"))
return path, items
def checklist_summary(items: Sequence[ChecklistItem]) -> Dict[str, Any]:
done = sum(1 for i in items if i.done)
total = len(items)
return {
"done": done,
"total": total,
"complete": total > 0 and done == total,
"pending": [i.item_id for i in items if not i.done],
}
def set_checklist_item_status(checklist_path: Path, item_id: str, status: str) -> bool:
"""Update a checklist row status by item id. Returns True if updated."""
text = checklist_path.read_text(encoding="utf-8")
lines = text.splitlines()
changed = False
out: List[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith("|"):
cells = [c.strip() for c in stripped.strip("|").split("|")]
if len(cells) >= 4 and cells[0] == item_id:
cells[2] = status
line = "| " + " | ".join(cells) + " |"
changed = True
out.append(line)
if changed:
checklist_path.write_text(
"\n".join(out) + ("\n" if text.endswith("\n") else ""), encoding="utf-8"
)
return changed
def set_phase(
eng: Engagement,
new_phase: str,
*,
force: bool = False,
notes: Optional[str] = None,
) -> Engagement:
"""Transition engagement phase; updates YAML on disk."""
new_phase = new_phase.strip()
if new_phase not in VALID_PHASES:
raise EngagementError(
f"Invalid phase {new_phase!r}; expected one of: {', '.join(VALID_PHASES)}"
)
current = eng.phase
if not force and new_phase != current:
allowed = PHASE_TRANSITIONS.get(current, ())
if new_phase not in allowed:
raise EngagementError(
f"Illegal phase transition {current!r}{new_phase!r}. "
f"Allowed: {', '.join(allowed) or '(terminal)'}. Use --force to override."
)
status = eng.data.setdefault("status", {})
if not isinstance(status, dict):
eng.data["status"] = {}
status = eng.data["status"]
status["phase"] = new_phase
if notes is not None:
status["notes"] = notes
meta = eng.data.setdefault("metadata", {})
if isinstance(meta, dict):
meta["updated"] = today_iso()
save_engagement(eng)
# Keep agent definition phase in sync if present
_sync_agent_definition_phase(eng, new_phase)
return load_engagement(eng.path)
def _sync_agent_definition_phase(eng: Engagement, phase: str) -> None:
ad_path = eng.agent_definition_path()
if ad_path is None or not ad_path.exists():
return
text = ad_path.read_text(encoding="utf-8")
if not text.startswith("---"):
return
parts = text.split("---", 2)
if len(parts) < 3:
return
try:
fm = yaml.safe_load(parts[1]) or {}
except yaml.YAMLError:
return
if not isinstance(fm, dict):
return
fm["phase"] = phase
new_fm = yaml.safe_dump(fm, sort_keys=False, allow_unicode=True).strip()
ad_path.write_text(f"---\n{new_fm}\n---{parts[2]}", encoding="utf-8")
def validate_engagement(eng: Engagement) -> List[str]:
"""Return list of validation errors (empty if ok)."""
errors: List[str] = []
if eng.data.get("apiVersion") != "kaizen.agentic/v1":
errors.append("apiVersion should be kaizen.agentic/v1")
if eng.phase not in VALID_PHASES:
errors.append(f"unknown phase: {eng.phase}")
if not eng.role_id:
errors.append("spec.role.id missing")
if not eng.targets:
errors.append("spec.targets is empty")
ad = eng.agent_definition_path()
if ad is None:
errors.append("spec.agent_definition.path missing")
elif not ad.exists():
errors.append(f"agent definition missing: {ad}")
mem = eng.vault_memory_path()
if not mem.exists():
errors.append(f"vault memory missing: {mem}")
for which in ("ramp_up", "ramp_down"):
path, items = load_checklist(eng, which)
if path is None:
errors.append(f"checklist missing: {which}")
elif not items:
errors.append(f"checklist empty or unparseable: {path}")
access = eng.path / "access-plan.md"
if not access.exists():
errors.append("access-plan.md missing")
return errors
def build_prepare_bundle(
eng: Engagement, repo_root: Optional[Path] = None
) -> Dict[str, Any]:
"""Assemble an offline session orientation bundle for this engagement."""
root = (repo_root or find_repo_root(eng.path)).resolve()
ad_path = eng.agent_definition_path()
agent_prompt = (
ad_path.read_text(encoding="utf-8") if ad_path and ad_path.exists() else None
)
mem_path = eng.vault_memory_path()
memory = mem_path.read_text(encoding="utf-8") if mem_path.exists() else None
access_path = eng.path / "access-plan.md"
access_plan = (
access_path.read_text(encoding="utf-8") if access_path.exists() else None
)
phase = eng.phase
phase_instructions = None
role_dir = root / DEFAULT_ROLES_DIR / (eng.role_id or "")
if phase == "ramp_up" and (role_dir / "ramp-up.md").exists():
phase_instructions = (role_dir / "ramp-up.md").read_text(encoding="utf-8")
elif phase == "ramp_down" and (role_dir / "ramp-down.md").exists():
phase_instructions = (role_dir / "ramp-down.md").read_text(encoding="utf-8")
protocols: Dict[str, Optional[str]] = {}
role_yaml = role_dir / "ROLE.yaml"
if role_yaml.exists():
try:
role_data = yaml.safe_load(role_yaml.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
role_data = {}
for p in (role_data.get("spec") or {}).get("protocols") or []:
if not isinstance(p, dict):
continue
slug = p.get("slug") or p.get("path")
rel = p.get("path")
content = None
if rel:
full = root / rel
if full.exists():
content = full.read_text(encoding="utf-8")
protocols[str(slug)] = content
_, ramp_up_items = load_checklist(eng, "ramp_up")
_, ramp_down_items = load_checklist(eng, "ramp_down")
return {
"engagement_id": eng.engagement_id,
"phase": phase,
"role_id": eng.role_id,
"targets": eng.targets,
"generated": today_iso(),
"path": str(eng.path),
"agent_prompt": agent_prompt,
"agent_prompt_found": agent_prompt is not None,
"memory": memory,
"access_plan": access_plan,
"phase_instructions": phase_instructions,
"protocols": {k: (v is not None) for k, v in protocols.items()},
"protocol_bodies": {k: v for k, v in protocols.items() if v},
"checklist_ramp_up": checklist_summary(ramp_up_items),
"checklist_ramp_down": checklist_summary(ramp_down_items),
"validation_errors": validate_engagement(eng),
"session_close": [
"Update vault memory (session log, findings, envelope)",
"Write reports/<date>-*.md for the duty",
"Append commercial/ledger.jsonl Kai duty charge (no secrets)",
"kaizen-agentic engagement checklist <id> # refresh status",
],
"confidentiality": "client_owned",
}
def render_prepare_markdown(bundle: Dict[str, Any]) -> str:
lines = [
f"# Engagement Prepare: {bundle['engagement_id']}",
f"Phase: {bundle['phase']}",
f"Role: {bundle.get('role_id')}",
f"Generated: {bundle['generated']}",
f"Path: {bundle['path']}",
"",
"## Targets",
]
for t in bundle.get("targets") or []:
lines.append(f"- {t.get('kind', 'target')}: `{t.get('id')}`")
lines.append("")
errs = bundle.get("validation_errors") or []
if errs:
lines.append("## Validation warnings")
for e in errs:
lines.append(f"- {e}")
lines.append("")
ru = bundle.get("checklist_ramp_up") or {}
rd = bundle.get("checklist_ramp_down") or {}
lines.append("## Checklists")
lines.append(
f"- ramp_up: {ru.get('done', 0)}/{ru.get('total', 0)}"
+ (f" pending={ru.get('pending')}" if ru.get("pending") else "")
)
lines.append(
f"- ramp_down: {rd.get('done', 0)}/{rd.get('total', 0)}"
+ (f" pending={rd.get('pending')}" if rd.get("pending") else "")
)
lines.append("")
lines.append("## Access Plan")
lines.append(bundle.get("access_plan") or "(missing access-plan.md)")
lines.append("")
if bundle.get("phase_instructions"):
lines.append("## Phase Instructions")
lines.append(bundle["phase_instructions"])
lines.append("")
lines.append("## Agent Definition")
if bundle.get("agent_prompt_found"):
lines.append(bundle["agent_prompt"])
else:
lines.append("(agent definition not found)")
lines.append("")
lines.append("## Vault Memory")
lines.append(bundle.get("memory") or "(no memory yet)")
lines.append("")
bodies = bundle.get("protocol_bodies") or {}
if bodies:
lines.append("## Protocols")
for slug, body in bodies.items():
lines.append(f"### {slug}")
lines.append(body)
lines.append("")
else:
present = bundle.get("protocols") or {}
if present:
lines.append("## Protocols (paths only)")
for slug, ok in present.items():
lines.append(f"- {slug}: {'loaded' if ok else 'missing'}")
lines.append("")
lines.append("## Session Close")
for cmd in bundle.get("session_close") or []:
lines.append(f"- {cmd}")
lines.append("")
lines.append(
"_Confidentiality: operational knowledge is client-owned; "
"do not copy vault content into supplier public agents._"
)
return "\n".join(lines)
def scrub_engagement(eng: Engagement) -> Dict[str, Any]:
"""Heuristic scan of vault + reports for sensitive patterns."""
hits: List[Dict[str, Any]] = []
scan_roots = [
eng.path / "vault",
eng.path / "reports",
eng.path / "access-plan.md",
]
files: List[Path] = []
for root in scan_roots:
if root.is_file():
files.append(root)
elif root.is_dir():
files.extend(
p
for p in root.rglob("*")
if p.is_file()
and p.suffix in {".md", ".txt", ".yml", ".yaml", ".json", ".jsonl"}
)
for fpath in files:
try:
text = fpath.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for label, pattern in _SCRUB_PATTERNS:
for match in pattern.finditer(text):
line_no = text[: match.start()].count("\n") + 1
hits.append(
{
"file": str(fpath.relative_to(eng.path)),
"line": line_no,
"pattern": label,
"snippet": match.group(0)[:40]
+ ("" if len(match.group(0)) > 40 else ""),
}
)
return {
"engagement_id": eng.engagement_id,
"files_scanned": len(files),
"hits": hits,
"clean": len(hits) == 0,
"note": "Heuristic only — human review required before contribute_lesson",
}
def export_handoff(eng: Engagement) -> Path:
"""Ensure handoff pack exists; return path to handoff directory."""
handoff = eng.path / "vault" / "handoff"
handoff.mkdir(parents=True, exist_ok=True)
readme = handoff / "README.md"
if not readme.exists():
readme.write_text(
f"# Handoff — {eng.engagement_id}\n\nGenerated: {today_iso()}\n",
encoding="utf-8",
)
risks = handoff / "risks.md"
if not risks.exists():
risks.write_text(
"# Outstanding risks\n\n| Risk | Severity | Status |\n|------|----------|--------|\n",
encoding="utf-8",
)
# Copy baseline files into handoff/baselines if present
baselines = eng.path / "vault" / "baselines"
if baselines.is_dir():
dest = handoff / "baselines"
dest.mkdir(exist_ok=True)
for f in baselines.iterdir():
if f.is_file():
shutil.copy2(f, dest / f.name)
return handoff
def staff_engagement(
*,
engagement_id: str,
role_id: str,
client_id: str,
target_id: str,
target_kind: str = "host",
repo_root: Optional[Path] = None,
force: bool = False,
) -> Engagement:
"""Scaffold a new pilot engagement directory from a Role package."""
root = (repo_root or find_repo_root()).resolve()
role_dir = root / DEFAULT_ROLES_DIR / role_id
if not role_dir.is_dir():
raise EngagementError(f"Role package not found: {role_dir}")
dest = root / DEFAULT_PILOTS_DIR / engagement_id
if dest.exists() and not force:
if (dest / ENGAGEMENT_FILENAME).exists():
return load_engagement(dest)
raise EngagementError(f"Directory exists without engagement: {dest}")
dest.mkdir(parents=True, exist_ok=True)
(dest / "vault" / "baselines").mkdir(parents=True, exist_ok=True)
(dest / "vault" / "session-log").mkdir(parents=True, exist_ok=True)
(dest / "vault" / "findings").mkdir(parents=True, exist_ok=True)
(dest / "vault" / "handoff").mkdir(parents=True, exist_ok=True)
(dest / "checklists").mkdir(parents=True, exist_ok=True)
(dest / "commercial").mkdir(parents=True, exist_ok=True)
(dest / "reports").mkdir(parents=True, exist_ok=True)
role_version = "0.1.0"
role_yaml = role_dir / "ROLE.yaml"
if role_yaml.exists():
try:
rd = yaml.safe_load(role_yaml.read_text(encoding="utf-8")) or {}
role_version = str(
(rd.get("metadata") or {}).get("version") or role_version
)
except yaml.YAMLError:
pass
# Memory from template
mem_tmpl = role_dir / "memory-template.md"
mem_path = dest / "vault" / "memory.md"
if mem_tmpl.exists():
mem_text = mem_tmpl.read_text(encoding="utf-8")
mem_text = mem_text.replace("<set on init>", engagement_id)
mem_text = mem_text.replace(
"<engagement or client slug>", f"{client_id}-{target_id}"
)
mem_text = mem_text.replace("<ISO date>", today_iso())
mem_path.write_text(mem_text, encoding="utf-8")
else:
mem_path.write_text(
f"---\nagent: {role_id}\nengagement_id: {engagement_id}\n"
f'last_updated: "{today_iso()}"\nsession_count: 0\n---\n\n# Memory\n',
encoding="utf-8",
)
# Agent definition from role template + binding frontmatter
ad_tmpl = role_dir / "agent-definition.md"
agent_name = f"agent-{role_id}.md"
if ad_tmpl.exists():
body = ad_tmpl.read_text(encoding="utf-8")
# Replace or inject engagement binding frontmatter
if body.startswith("---"):
parts = body.split("---", 2)
fm = yaml.safe_load(parts[1]) if len(parts) >= 3 else {}
if not isinstance(fm, dict):
fm = {}
fm.update(
{
"engagement_id": engagement_id,
"role_id": role_id,
"role_version": role_version,
"phase": "staffing",
"memory_path": f"engagements/pilots/{engagement_id}/vault/memory.md",
"targets": [{"kind": target_kind, "id": target_id}],
"confidentiality": "client_owned",
"access_classes": ["host_observe", "privileged_ops"],
"human_approval_for": [
"privileged_ops",
"package_upgrade",
"firewall_change",
"reboot",
],
}
)
new_fm = yaml.safe_dump(fm, sort_keys=False, allow_unicode=True).strip()
body = f"---\n{new_fm}\n---{parts[2] if len(parts) >= 3 else ''}"
(dest / agent_name).write_text(body, encoding="utf-8")
else:
(dest / agent_name).write_text(
f"---\nname: {role_id}\nengagement_id: {engagement_id}\nphase: staffing\n---\n\n# {role_id}\n",
encoding="utf-8",
)
# Checklists from role ramp docs (status tables)
for which, fname, prefix in (
("ramp_up", "ramp-up-status.md", "RU"),
("ramp_down", "ramp-down-status.md", "RD"),
):
role_check = role_dir / ("ramp-up.md" if which == "ramp_up" else "ramp-down.md")
dest_check = dest / "checklists" / fname
if role_check.exists():
# Extract table rows from role checklist
items = parse_checklist_markdown(role_check.read_text(encoding="utf-8"))
lines = [
f"# {which.replace('_', '-').title()} status — {engagement_id}",
"",
f"Source checklist: `roles/{role_id}/{role_check.name}`",
"",
"| ID | Criterion | Status | Evidence |",
"|----|-----------|--------|----------|",
]
for it in items:
lines.append(
f"| {it.item_id} | {it.criterion} | todo | {it.evidence} |"
)
if not items:
lines.append(
f"| {prefix}-01 | Complete {which} | todo | see role checklist |"
)
dest_check.write_text("\n".join(lines) + "\n", encoding="utf-8")
else:
dest_check.write_text(
f"# {which}\n\n| ID | Criterion | Status | Evidence |\n"
f"|----|-----------|--------|----------|\n"
f"| {prefix}-01 | Complete | todo | |\n",
encoding="utf-8",
)
(dest / "access-plan.md").write_text(
f"# Access plan — {engagement_id}\n\n"
f"**Target:** {target_kind} `{target_id}`\n\n"
"Secrets: never stored in this tree.\n\n"
"## Verification log\n\n| Date | Result | Notes |\n|------|--------|-------|\n| _pending_ | | RU-01 |\n",
encoding="utf-8",
)
(dest / "schedule.yml").write_text(
f"engagement_id: {engagement_id}\ntimezone: Europe/Berlin\nentries: []\n",
encoding="utf-8",
)
(dest / "vault" / "baselines" / f"{target_id}.md").write_text(
f"# Baseline — {target_id}\n\n**Status:** pending\n",
encoding="utf-8",
)
(dest / "commercial" / "ledger.jsonl").write_text("", encoding="utf-8")
(dest / "commercial" / "quote.yaml").write_text(
f"apiVersion: kaizen.agentic/v1\nkind: KaiQuote\nmetadata:\n"
f' engagement_id: {engagement_id}\n created: "{today_iso()}"\n'
f" currency: KAI\nspec:\n total_kai: 0\n notes: [scaffold — fill quote]\n",
encoding="utf-8",
)
(dest / "request.yaml").write_text(
f"apiVersion: kaizen.agentic/v1\nkind: EngagementRequest\n"
f'metadata:\n id: engreq-{engagement_id}\n created: "{today_iso()}"\n'
f"spec:\n client:\n id: {client_id}\n role:\n id: {role_id}\n"
f" targets:\n - kind: {target_kind}\n id: {target_id}\n",
encoding="utf-8",
)
eng_doc = {
"apiVersion": "kaizen.agentic/v1",
"kind": "Engagement",
"metadata": {
"id": engagement_id,
"created": today_iso(),
"updated": today_iso(),
},
"status": {
"phase": "staffing",
"notes": "Scaffolded by kaizen-agentic engagement staff",
},
"spec": {
"client": {"id": client_id},
"role": {"id": role_id, "version": role_version},
"targets": [{"kind": target_kind, "id": target_id}],
"agent_definition": {
"path": agent_name,
"derived_from": f"roles/{role_id}/agent-definition.md",
},
"vault": {"root": "vault/", "memory": "vault/memory.md"},
"schedule": {"path": "schedule.yml"},
"checklists": {
"ramp_up": "checklists/ramp-up-status.md",
"ramp_down": "checklists/ramp-down-status.md",
},
"policy": {
"confidentiality": "client_owned",
"contribute_metrics": False,
"human_approval_for": [
"privileged_ops",
"package_upgrade",
"firewall_change",
"reboot",
],
},
"commercial": {
"currency": "kai",
"ledger_ref": "commercial/ledger.jsonl",
"quote_ref": "commercial/quote.yaml",
},
},
}
(dest / ENGAGEMENT_FILENAME).write_text(
yaml.safe_dump(eng_doc, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
return load_engagement(dest)
def load_quote(eng: Engagement) -> Optional[Dict[str, Any]]:
path = eng.quote_path()
if path is None:
return None
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise EngagementError(f"Invalid quote YAML: {exc}") from exc
return data if isinstance(data, dict) else None