kaizen-agentic/src/kaizen_agentic/engagement.py
tegwick d64e50065a
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
ci / test (push) Successful in 2m37s
Publish Python package / publish (push) Successful in 1m25s
fix: clear flake8 errors blocking Forgejo CI
Break long lines in engagement scaffolding, drop unused Callable import,
and simplify the metrics-cron f-string replace in engagement_promote.
2026-07-18 18:07:00 +02:00

1186 lines
42 KiB
Python

"""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 json
import re
import shutil
from dataclasses import dataclass
from datetime import date, datetime, timezone
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+")),
]
# Kai duty catalog (base units before capability tier multiplier) — business model §5.4
DUTY_BASE_KAI: Dict[str, int] = {
"short_assist": 100,
"standard_review": 400,
"deep_assessment": 1200,
"ramp_up_package": 5000,
"ramp_down_package": 3000,
}
ACCESS_SURCHARGE_KAI: Dict[str, int] = {
"read_only": 0,
"host_observe": 100,
"privileged_ops": 400,
}
# Package duties use half tier weight per business model
DUTY_HALF_TIER = frozenset({"ramp_up_package", "ramp_down_package"})
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 utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def text_has_sensitive_content(text: str) -> List[str]:
"""Return labels of scrub patterns that match text (empty if clean)."""
hits: List[str] = []
for label, pattern in _SCRUB_PATTERNS:
if pattern.search(text):
hits.append(label)
return hits
def capability_tier(eng: Engagement) -> int:
spec = eng.data.get("spec") or {}
commercial = spec.get("commercial") or {}
tier = commercial.get("capability_tier")
if tier is not None:
try:
return max(1, int(tier))
except (TypeError, ValueError):
pass
return 4
def compute_duty_kai(
duty: str,
*,
tier: int = 4,
access_class: Optional[str] = None,
amount_override: Optional[int] = None,
) -> int:
"""Compute Kai charge for a duty (+ optional access surcharge)."""
if amount_override is not None:
return max(0, int(amount_override))
if duty not in DUTY_BASE_KAI:
raise EngagementError(
f"Unknown duty {duty!r}; expected one of: {', '.join(sorted(DUTY_BASE_KAI))}"
)
base = DUTY_BASE_KAI[duty]
if duty in DUTY_HALF_TIER:
amount = int(base * tier / 2)
else:
amount = base * tier
if access_class:
if access_class not in ACCESS_SURCHARGE_KAI:
raise EngagementError(
f"Unknown access class {access_class!r}; "
f"expected one of: {', '.join(sorted(ACCESS_SURCHARGE_KAI))}"
)
amount += ACCESS_SURCHARGE_KAI[access_class]
return amount
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": [
(
f"kaizen-agentic engagement close-session {eng.engagement_id} "
"--success --duty standard_review --summary '<one-line outcome>' "
"--time <seconds> --quality <0-1>"
),
"Or manually: update vault memory, reports/, commercial/ledger.jsonl",
f"kaizen-agentic engagement checklist {eng.engagement_id}",
],
"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}\n"
f"phase: 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
def append_session_log(eng: Engagement, line: str) -> Path:
"""Append one session log line to vault memory under ## Session Log."""
sensitive = text_has_sensitive_content(line)
if sensitive:
raise EngagementError(
f"Session summary rejected — looks sensitive ({', '.join(sensitive)}). "
"Use a non-secret one-line outcome."
)
mem_path = eng.vault_memory_path()
mem_path.parent.mkdir(parents=True, exist_ok=True)
if not mem_path.exists():
mem_path.write_text(
f"---\nagent: {eng.role_id or 'agent'}\n"
f"engagement_id: {eng.engagement_id}\n"
f'last_updated: "{today_iso()}"\nsession_count: 0\n---\n\n'
f"# Memory\n\n## Session Log\n\n",
encoding="utf-8",
)
text = mem_path.read_text(encoding="utf-8")
# Bump session_count / last_updated in frontmatter when present
if text.startswith("---"):
parts = text.split("---", 2)
if len(parts) >= 3:
try:
fm = yaml.safe_load(parts[1]) or {}
except yaml.YAMLError:
fm = {}
if isinstance(fm, dict):
try:
fm["session_count"] = int(fm.get("session_count") or 0) + 1
except (TypeError, ValueError):
fm["session_count"] = 1
fm["last_updated"] = today_iso()
new_fm = yaml.safe_dump(fm, sort_keys=False, allow_unicode=True).strip()
text = f"---\n{new_fm}\n---{parts[2]}"
log_line = f"- {today_iso()} · {line.strip()}"
if "## Session Log" in text:
# Append after the heading (and any existing log lines)
idx = text.index("## Session Log")
after = text[idx + len("## Session Log") :]
# Find next ## heading or end
next_h = re.search(r"\n## ", after)
if next_h:
insert_at = idx + len("## Session Log") + next_h.start()
text = text[:insert_at].rstrip() + "\n" + log_line + "\n" + text[insert_at:]
else:
text = text.rstrip() + "\n" + log_line + "\n"
else:
text = text.rstrip() + "\n\n## Session Log\n\n" + log_line + "\n"
mem_path.write_text(text, encoding="utf-8")
return mem_path
def append_kai_ledger_entry(eng: Engagement, entry: Dict[str, Any]) -> Path:
"""Append one KaiLedgerEntry JSON line. Rejects sensitive payload fields."""
for key in ("summary", "notes", "detail", "transcript"):
val = entry.get(key)
if isinstance(val, str):
hits = text_has_sensitive_content(val)
if hits:
raise EngagementError(
f"Ledger field {key!r} rejected — sensitive pattern ({', '.join(hits)})"
)
# Never allow raw vault dumps
forbidden = {"memory", "agent_prompt", "vault", "secret", "password", "token"}
for key in entry:
if key.lower() in forbidden:
raise EngagementError(f"Ledger must not include field {key!r}")
path = eng.ledger_path()
path.parent.mkdir(parents=True, exist_ok=True)
payload = dict(entry)
payload.setdefault("apiVersion", "kaizen.agentic/v1")
payload.setdefault("kind", "KaiLedgerEntry")
payload.setdefault("engagement_id", eng.engagement_id)
payload.setdefault("currency", "KAI")
payload.setdefault("created_at", utc_now_iso())
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload, sort_keys=True))
handle.write("\n")
return path
def write_session_report(
eng: Engagement,
*,
duty: str,
summary: str,
success: bool,
report_name: Optional[str] = None,
) -> Path:
"""Write a short client-visible duty report under reports/."""
hits = text_has_sensitive_content(summary)
if hits:
raise EngagementError(
f"Report summary rejected — sensitive pattern ({', '.join(hits)})"
)
reports = eng.path / "reports"
reports.mkdir(parents=True, exist_ok=True)
name = report_name or f"{today_iso()}-{duty.replace('_', '-')}.md"
path = reports / name
target_ids = ", ".join(
str(t.get("id")) for t in eng.targets if isinstance(t, dict) and t.get("id")
)
body = (
f"# Session report — {eng.engagement_id}\n\n"
f"- **Date:** {today_iso()}\n"
f"- **Duty:** {duty}\n"
f"- **Targets:** {target_ids or 'n/a'}\n"
f"- **Outcome:** {'success' if success else 'failure'}\n"
f"- **Phase:** {eng.phase}\n\n"
f"## Summary\n\n{summary.strip()}\n\n"
f"_Billing metadata only in commercial/ledger.jsonl; "
f"no secrets in this report._\n"
)
path.write_text(body, encoding="utf-8")
return path
def close_session(
eng: Engagement,
*,
success: bool,
duty: str = "standard_review",
summary: str,
execution_time_s: Optional[float] = None,
quality: Optional[float] = None,
access_class: Optional[str] = "host_observe",
amount_kai: Optional[int] = None,
record_metrics: bool = True,
record_ledger: bool = True,
write_report: bool = True,
agent_name: Optional[str] = None,
idempotency_key: Optional[str] = None,
) -> Dict[str, Any]:
"""Session-close: vault log + optional metrics + Kai duty charge + report.
Metrics are stored under the engagement path (``.kaizen/metrics/<agent>/``)
so they stay with the client vault tree. Ledger lines never include secrets.
"""
from .metrics import MetricsStore # local import avoids circular weight
agent = agent_name or eng.role_id or "host-operator"
tier = capability_tier(eng)
result: Dict[str, Any] = {
"engagement_id": eng.engagement_id,
"duty": duty,
"success": success,
"agent": agent,
"phase": eng.phase,
}
# 1) Vault session log
target_hint = ""
if eng.targets:
tid = eng.targets[0].get("id") if isinstance(eng.targets[0], dict) else None
if tid:
target_hint = f"{tid} · "
outcome = "ok" if success else "fail"
log_line = f"{target_hint}{duty} · {summary.strip()} · {outcome}"
mem_path = append_session_log(eng, log_line)
result["memory_path"] = str(mem_path)
# 2) Report
report_path = None
if write_report:
report_path = write_session_report(
eng, duty=duty, summary=summary, success=success
)
result["report_path"] = str(report_path)
# 3) Metrics (engagement-scoped)
if record_metrics:
store = MetricsStore(eng.path, agent)
payload: Dict[str, Any] = {
"success": success,
"engagement_id": eng.engagement_id,
"duty": duty,
"phase": eng.phase,
}
if execution_time_s is not None:
payload["execution_time_s"] = float(execution_time_s)
if quality is not None:
payload["quality_score"] = float(quality)
recorded = store.append(payload, idempotency_key=idempotency_key)
result["metrics_recorded"] = recorded
result["metrics_path"] = str(store.executions_path)
else:
result["metrics_recorded"] = False
# 4) Kai ledger
if record_ledger:
kai = compute_duty_kai(
duty,
tier=tier,
access_class=access_class,
amount_override=amount_kai,
)
entry_id = f"kai-{utc_now_iso().replace(':', '').replace('-', '')}-{duty}"
target_id = None
if eng.targets and isinstance(eng.targets[0], dict):
target_id = eng.targets[0].get("id")
ledger_entry: Dict[str, Any] = {
"id": entry_id,
"type": "duty_charge",
"product": duty,
"capability_tier": tier,
"amount_kai": kai,
"session_ref": (
str(Path(result["report_path"]).relative_to(eng.path))
if result.get("report_path")
else None
),
"metadata": {
"target": target_id,
"phase": eng.phase,
"success": success,
"access_class": access_class,
},
}
if access_class:
ledger_entry["access_surcharge_product"] = access_class
# Billing account if present
client = (eng.data.get("spec") or {}).get("client") or {}
if client.get("billing_account"):
ledger_entry["account"] = client["billing_account"]
ledger_path = append_kai_ledger_entry(eng, ledger_entry)
result["ledger_path"] = str(ledger_path)
result["amount_kai"] = kai
result["ledger_entry_id"] = entry_id
else:
result["amount_kai"] = 0
return result