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
|
||||
|
||||
### 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
|
||||
envelope for activity-core event-driven definitions (optional `nats-py` via
|
||||
`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
|
||||
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
|
||||
```bash
|
||||
# List templates
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
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
|
||||
title: JSONL Kai ledger convention and quote snapshot for pilot
|
||||
- id: T08
|
||||
status: todo
|
||||
status: done
|
||||
title: CLI engagement command group (request, staff, prepare, checklist, scrub)
|
||||
- id: T09
|
||||
status: todo
|
||||
|
|
@ -186,18 +186,17 @@ with quote-open note, commercial README.
|
|||
|
||||
```task
|
||||
id: KAIZEN-WP-0009-T08
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
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`,
|
||||
`prepare`, `scrub`, `export-handoff` — minimum viable subset for pilot may be
|
||||
`staff`, `checklist`, `prepare`, `phase`.
|
||||
`list`, `show`, `validate`, `checklist` (+ `--mark`), `phase`, `prepare`,
|
||||
`staff`, `quote`, `scrub`, `export-handoff`.
|
||||
|
||||
Tests for schema validate and checklist status.
|
||||
Tests: `tests/test_engagement_cli.py`. Docs: CLI cheat sheet section.
|
||||
|
||||
## Session wire-up
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue