Break long lines in engagement scaffolding, drop unused Callable import, and simplify the metrics-cron f-string replace in engagement_promote.
1186 lines
42 KiB
Python
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
|