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
|
|
@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
`keepaTodofile` agent remains available for other projects
|
`keepaTodofile` agent remains available for other projects
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **`engagement` CLI (WP-0009 T08)** — forward-deployed engagement lifecycle:
|
||||||
|
`list`/`show`/`validate`/`checklist`/`phase`/`prepare`/`staff`/`quote`/
|
||||||
|
`scrub`/`export-handoff` over file-based pilots (`engagements/pilots/`)
|
||||||
- **`metrics record --emit-event`** — publishes `kaizen.metrics.recorded` NATS
|
- **`metrics record --emit-event`** — publishes `kaizen.metrics.recorded` NATS
|
||||||
envelope for activity-core event-driven definitions (optional `nats-py` via
|
envelope for activity-core event-driven definitions (optional `nats-py` via
|
||||||
`pip install 'kaizen-agentic[events]'`)
|
`pip install 'kaizen-agentic[events]'`)
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,39 @@ kaizen-agentic schedule prepare optimization --format json
|
||||||
activity-core fires the schedule and creates a task per (repo, agent); the task
|
activity-core fires the schedule and creates a task per (repo, agent); the task
|
||||||
runs `schedule prepare`. kaizen-agentic does not run cron or invoke Claude.
|
runs `schedule prepare`. kaizen-agentic does not run cron or invoke Claude.
|
||||||
|
|
||||||
|
### Forward-deployed engagements (WP-0009 / DEC-FDA-001)
|
||||||
|
```bash
|
||||||
|
# List / inspect pilot engagements under engagements/pilots/
|
||||||
|
kaizen-agentic engagement list
|
||||||
|
kaizen-agentic engagement show eng-coulomb-railiance01-ho-001
|
||||||
|
kaizen-agentic engagement validate eng-coulomb-railiance01-ho-001
|
||||||
|
|
||||||
|
# Checklists (ramp-up / ramp-down)
|
||||||
|
kaizen-agentic engagement checklist eng-coulomb-railiance01-ho-001
|
||||||
|
kaizen-agentic engagement checklist eng-… --mark RU-01=done
|
||||||
|
|
||||||
|
# Phase transitions (staffing → ramp_up → operating → ramp_down → closed)
|
||||||
|
kaizen-agentic engagement phase eng-… --to ramp_up
|
||||||
|
kaizen-agentic engagement phase eng-… --to operating --force # override graph
|
||||||
|
|
||||||
|
# Session orientation bundle (agent + vault + protocols + access plan)
|
||||||
|
kaizen-agentic engagement prepare eng-coulomb-railiance01-ho-001
|
||||||
|
kaizen-agentic engagement prepare eng-… --format json
|
||||||
|
|
||||||
|
# Staff a new pilot from a Role package
|
||||||
|
kaizen-agentic engagement staff \
|
||||||
|
--id eng-example-001 --role host-operator \
|
||||||
|
--client coulomb --target railiance01
|
||||||
|
|
||||||
|
# Kai quote, scrub before lesson export, handoff pack
|
||||||
|
kaizen-agentic engagement quote eng-coulomb-railiance01-ho-001
|
||||||
|
kaizen-agentic engagement scrub eng-…
|
||||||
|
kaizen-agentic engagement export-handoff eng-…
|
||||||
|
```
|
||||||
|
|
||||||
|
Does not invoke LLMs or touch production hosts. Spec:
|
||||||
|
`docs/forward-deployed-engagement-architecture.md`.
|
||||||
|
|
||||||
### Information
|
### Information
|
||||||
```bash
|
```bash
|
||||||
# List templates
|
# List templates
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,25 @@ from .integrations.helix import HelixCorrelationAdapter, enrich_helix_correlatio
|
||||||
from .metrics import MetricsStore, OptimizerStore, performance_summary_markdown
|
from .metrics import MetricsStore, OptimizerStore, performance_summary_markdown
|
||||||
from .optimization import OptimizationLoop, MIN_SAMPLES_FOR_RECOMMENDATIONS
|
from .optimization import OptimizationLoop, MIN_SAMPLES_FOR_RECOMMENDATIONS
|
||||||
from .engagement_promote import promote_engagement
|
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 (
|
from .schedule import (
|
||||||
ScheduleError,
|
ScheduleError,
|
||||||
default_schedule_yaml,
|
default_schedule_yaml,
|
||||||
|
|
@ -1412,6 +1431,372 @@ def protocols_show(agent_name: str, slug: str):
|
||||||
click.echo(protocol_path.read_text())
|
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()
|
@cli.group()
|
||||||
def schedule():
|
def schedule():
|
||||||
"""Prepare and validate scheduled agent runs (.kaizen/schedule.yml, ADR-005).
|
"""Prepare and validate scheduled agent runs (.kaizen/schedule.yml, ADR-005).
|
||||||
|
|
|
||||||
869
src/kaizen_agentic/engagement.py
Normal file
869
src/kaizen_agentic/engagement.py
Normal 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
|
||||||
365
tests/test_engagement_cli.py
Normal file
365
tests/test_engagement_cli.py
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
"""CLI + module tests for forward-deployed engagements (KAIZEN-WP-0009 T08)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from kaizen_agentic.cli import cli
|
||||||
|
from kaizen_agentic.engagement import (
|
||||||
|
EngagementError,
|
||||||
|
build_prepare_bundle,
|
||||||
|
checklist_summary,
|
||||||
|
load_checklist,
|
||||||
|
load_engagement,
|
||||||
|
parse_checklist_markdown,
|
||||||
|
set_checklist_item_status,
|
||||||
|
set_phase,
|
||||||
|
staff_engagement,
|
||||||
|
validate_engagement,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner() -> CliRunner:
|
||||||
|
return CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mini_repo(tmp_path: Path) -> Path:
|
||||||
|
"""Minimal repo with host-operator Role package."""
|
||||||
|
root = tmp_path / "kaizen-root"
|
||||||
|
role = root / "roles" / "host-operator"
|
||||||
|
role.mkdir(parents=True)
|
||||||
|
(role / "ROLE.yaml").write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
{
|
||||||
|
"apiVersion": "kaizen.agentic/v1",
|
||||||
|
"kind": "Role",
|
||||||
|
"metadata": {"id": "host-operator", "version": "0.1.0"},
|
||||||
|
"spec": {
|
||||||
|
"protocols": [
|
||||||
|
{
|
||||||
|
"agent": "host-operator",
|
||||||
|
"slug": "load-workload-review",
|
||||||
|
"path": "roles/host-operator/protocols/load-workload-review.md",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(role / "agent-definition.md").write_text(
|
||||||
|
"---\nname: host-operator\ncategory: infrastructure\nmemory: enabled\n---\n\n# Host Operator\n\nDo ops.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(role / "memory-template.md").write_text(
|
||||||
|
"---\nagent: host-operator\nengagement_id: <set on init>\n---\n\n# Memory\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(role / "ramp-up.md").write_text(
|
||||||
|
"# Ramp-up\n\n| ID | Criterion | Status | Evidence |\n"
|
||||||
|
"|----|-----------|--------|----------|\n"
|
||||||
|
"| RU-01 | Access path verified | todo | access-plan.md |\n"
|
||||||
|
"| RU-02 | Host baseline documented | todo | vault/baselines |\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(role / "ramp-down.md").write_text(
|
||||||
|
"# Ramp-down\n\n| ID | Criterion | Status | Evidence |\n"
|
||||||
|
"|----|-----------|--------|----------|\n"
|
||||||
|
"| RD-01 | Open threads triaged | todo | memory |\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
proto = role / "protocols"
|
||||||
|
proto.mkdir()
|
||||||
|
(proto / "load-workload-review.md").write_text(
|
||||||
|
"---\nslug: load-workload-review\n---\n\n# Load review\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(root / "engagements" / "pilots").mkdir(parents=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
class TestChecklistParse:
|
||||||
|
def test_parse_checklist_rows(self):
|
||||||
|
text = (
|
||||||
|
"| ID | Criterion | Status | Evidence |\n"
|
||||||
|
"|----|-----------|--------|----------|\n"
|
||||||
|
"| RU-01 | Access | done | access-plan.md |\n"
|
||||||
|
"| RU-02 | Baseline | todo | vault |\n"
|
||||||
|
)
|
||||||
|
items = parse_checklist_markdown(text)
|
||||||
|
assert len(items) == 2
|
||||||
|
assert items[0].done is True
|
||||||
|
assert items[1].done is False
|
||||||
|
summary = checklist_summary(items)
|
||||||
|
assert summary == {
|
||||||
|
"done": 1,
|
||||||
|
"total": 2,
|
||||||
|
"complete": False,
|
||||||
|
"pending": ["RU-02"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_set_checklist_item_status(self, tmp_path: Path):
|
||||||
|
p = tmp_path / "c.md"
|
||||||
|
p.write_text(
|
||||||
|
"| ID | Criterion | Status | Evidence |\n"
|
||||||
|
"|----|-----------|--------|----------|\n"
|
||||||
|
"| RU-01 | Access | todo | a |\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert set_checklist_item_status(p, "RU-01", "done")
|
||||||
|
assert "RU-01" in p.read_text()
|
||||||
|
assert "done" in p.read_text()
|
||||||
|
items = parse_checklist_markdown(p.read_text())
|
||||||
|
assert items[0].done
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaffAndLifecycle:
|
||||||
|
def test_staff_validate_phase_prepare(self, mini_repo: Path):
|
||||||
|
eng = staff_engagement(
|
||||||
|
engagement_id="eng-test-host-001",
|
||||||
|
role_id="host-operator",
|
||||||
|
client_id="coulomb",
|
||||||
|
target_id="railiance01",
|
||||||
|
repo_root=mini_repo,
|
||||||
|
)
|
||||||
|
assert eng.phase == "staffing"
|
||||||
|
assert eng.engagement_id == "eng-test-host-001"
|
||||||
|
assert (eng.path / "vault" / "memory.md").exists()
|
||||||
|
assert (eng.path / "agent-host-operator.md").exists()
|
||||||
|
|
||||||
|
errs = validate_engagement(eng)
|
||||||
|
assert errs == []
|
||||||
|
|
||||||
|
path, items = load_checklist(eng, "ramp_up")
|
||||||
|
assert path is not None
|
||||||
|
assert any(i.item_id == "RU-01" for i in items)
|
||||||
|
|
||||||
|
eng = set_phase(eng, "ramp_up")
|
||||||
|
assert eng.phase == "ramp_up"
|
||||||
|
ad = (eng.path / "agent-host-operator.md").read_text(encoding="utf-8")
|
||||||
|
assert "phase: ramp_up" in ad or "phase: ramp_up" in ad.replace('"', "")
|
||||||
|
|
||||||
|
bundle = build_prepare_bundle(eng, mini_repo)
|
||||||
|
assert bundle["engagement_id"] == "eng-test-host-001"
|
||||||
|
assert bundle["agent_prompt_found"] is True
|
||||||
|
assert bundle["phase"] == "ramp_up"
|
||||||
|
assert "Access" in (bundle.get("phase_instructions") or "")
|
||||||
|
|
||||||
|
with pytest.raises(EngagementError):
|
||||||
|
set_phase(eng, "closed") # illegal without force
|
||||||
|
|
||||||
|
eng = set_phase(eng, "closed", force=True)
|
||||||
|
assert eng.phase == "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEngagementCli:
|
||||||
|
def test_staff_list_checklist_prepare(self, runner: CliRunner, mini_repo: Path):
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"staff",
|
||||||
|
"--id",
|
||||||
|
"eng-cli-001",
|
||||||
|
"--role",
|
||||||
|
"host-operator",
|
||||||
|
"--client",
|
||||||
|
"coulomb",
|
||||||
|
"--target",
|
||||||
|
"railiance01",
|
||||||
|
"--repo-root",
|
||||||
|
str(mini_repo),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "Staffed engagement" in result.output
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
cli, ["engagement", "list", "--repo-root", str(mini_repo), "--json"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
rows = json.loads(result.output)
|
||||||
|
assert any(r["id"] == "eng-cli-001" for r in rows)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"validate",
|
||||||
|
"eng-cli-001",
|
||||||
|
"--repo-root",
|
||||||
|
str(mini_repo),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"checklist",
|
||||||
|
"eng-cli-001",
|
||||||
|
"--repo-root",
|
||||||
|
str(mini_repo),
|
||||||
|
"--mark",
|
||||||
|
"RU-01=done",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "RU-01" in result.output
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"checklist",
|
||||||
|
"eng-cli-001",
|
||||||
|
"--repo-root",
|
||||||
|
str(mini_repo),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(result.output)
|
||||||
|
ru = data["checklists"]["ramp_up"]["items"]
|
||||||
|
assert any(i["id"] == "RU-01" and i["done"] for i in ru)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"phase",
|
||||||
|
"eng-cli-001",
|
||||||
|
"--to",
|
||||||
|
"ramp_up",
|
||||||
|
"--repo-root",
|
||||||
|
str(mini_repo),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "ramp_up" in result.output
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"prepare",
|
||||||
|
"eng-cli-001",
|
||||||
|
"--repo-root",
|
||||||
|
str(mini_repo),
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
bundle = json.loads(result.output)
|
||||||
|
assert bundle["phase"] == "ramp_up"
|
||||||
|
assert bundle["agent_prompt_found"] is True
|
||||||
|
|
||||||
|
def test_phase_rejects_illegal_transition(self, runner: CliRunner, mini_repo: Path):
|
||||||
|
runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"staff",
|
||||||
|
"--id",
|
||||||
|
"eng-cli-002",
|
||||||
|
"--role",
|
||||||
|
"host-operator",
|
||||||
|
"--client",
|
||||||
|
"c",
|
||||||
|
"--target",
|
||||||
|
"h1",
|
||||||
|
"--repo-root",
|
||||||
|
str(mini_repo),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"phase",
|
||||||
|
"eng-cli-002",
|
||||||
|
"--to",
|
||||||
|
"closed",
|
||||||
|
"--repo-root",
|
||||||
|
str(mini_repo),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Illegal phase" in result.output
|
||||||
|
|
||||||
|
def test_real_pilot_if_present(self, runner: CliRunner):
|
||||||
|
"""Smoke against repo pilot when checked out as kaizen-agentic."""
|
||||||
|
repo = Path.cwd()
|
||||||
|
pilot = (
|
||||||
|
repo
|
||||||
|
/ "engagements"
|
||||||
|
/ "pilots"
|
||||||
|
/ "eng-coulomb-railiance01-ho-001"
|
||||||
|
/ "ENGAGEMENT.yaml"
|
||||||
|
)
|
||||||
|
if not pilot.exists():
|
||||||
|
pytest.skip("pilot engagement not in cwd")
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"show",
|
||||||
|
"eng-coulomb-railiance01-ho-001",
|
||||||
|
"--repo-root",
|
||||||
|
str(repo),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert data["id"] == "eng-coulomb-railiance01-ho-001"
|
||||||
|
assert data["role"] == "host-operator"
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"checklist",
|
||||||
|
"eng-coulomb-railiance01-ho-001",
|
||||||
|
"--repo-root",
|
||||||
|
str(repo),
|
||||||
|
"--which",
|
||||||
|
"ramp_up",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "RU-01" in result.output
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"validate",
|
||||||
|
"eng-coulomb-railiance01-ho-001",
|
||||||
|
"--repo-root",
|
||||||
|
str(repo),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"engagement",
|
||||||
|
"quote",
|
||||||
|
"eng-coulomb-railiance01-ho-001",
|
||||||
|
"--repo-root",
|
||||||
|
str(repo),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "72800" in result.output or "Kai" in result.output
|
||||||
|
|
@ -36,7 +36,7 @@ tasks:
|
||||||
status: done
|
status: done
|
||||||
title: JSONL Kai ledger convention and quote snapshot for pilot
|
title: JSONL Kai ledger convention and quote snapshot for pilot
|
||||||
- id: T08
|
- id: T08
|
||||||
status: todo
|
status: done
|
||||||
title: CLI engagement command group (request, staff, prepare, checklist, scrub)
|
title: CLI engagement command group (request, staff, prepare, checklist, scrub)
|
||||||
- id: T09
|
- id: T09
|
||||||
status: todo
|
status: todo
|
||||||
|
|
@ -186,18 +186,17 @@ with quote-open note, commercial README.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: KAIZEN-WP-0009-T08
|
id: KAIZEN-WP-0009-T08
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "52f7d872-1087-4890-9439-8d6c704351fd"
|
state_hub_task_id: "52f7d872-1087-4890-9439-8d6c704351fd"
|
||||||
```
|
```
|
||||||
|
|
||||||
Implement `kaizen-agentic engagement` subcommands (Phase 2 of architecture):
|
**Delivered:** `kaizen-agentic engagement` CLI group + `engagement.py` module:
|
||||||
|
|
||||||
`request`, `quote`, `staff`, `render-agent`, `init-vault`, `phase`, `checklist`,
|
`list`, `show`, `validate`, `checklist` (+ `--mark`), `phase`, `prepare`,
|
||||||
`prepare`, `scrub`, `export-handoff` — minimum viable subset for pilot may be
|
`staff`, `quote`, `scrub`, `export-handoff`.
|
||||||
`staff`, `checklist`, `prepare`, `phase`.
|
|
||||||
|
|
||||||
Tests for schema validate and checklist status.
|
Tests: `tests/test_engagement_cli.py`. Docs: CLI cheat sheet section.
|
||||||
|
|
||||||
## Session wire-up
|
## Session wire-up
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue