feat: engagement close-session wires vault, metrics, Kai ledger (WP-0009 T09)
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 9m26s
ci / test (push) Failing after 4s

Add close_session helpers and CLI to append vault session logs, engagement-
scoped metrics, duty Kai charges, and client reports. Rejects sensitive
summaries. Pilot smoke-closed once; prepare points operators at close-session.
This commit is contained in:
tegwick 2026-07-16 12:09:05 +02:00
parent ad5965be91
commit 4532196546
11 changed files with 588 additions and 14 deletions

View file

@ -25,8 +25,10 @@ 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 (
DUTY_BASE_KAI,
EngagementError,
build_prepare_bundle,
close_session,
export_handoff,
find_repo_root,
list_engagement_dirs,
@ -1797,6 +1799,119 @@ def engagement_export_handoff(engagement_ref: str, repo_root: Optional[str]):
click.echo(f"Handoff pack: {handoff}")
@engagement.command("close-session")
@click.argument("engagement_ref")
@click.option("--success", "outcome_success", is_flag=True, help="Session succeeded")
@click.option("--failure", "outcome_failure", is_flag=True, help="Session failed")
@click.option(
"--duty",
type=click.Choice(sorted(DUTY_BASE_KAI.keys())),
default="standard_review",
show_default=True,
help="Duty product for Kai charge",
)
@click.option(
"--summary",
required=True,
help="One-line non-secret outcome (session log + report)",
)
@click.option("--time", "execution_time", type=float, help="Wall time seconds")
@click.option("--quality", type=float, help="Quality score 0.01.0")
@click.option(
"--access-class",
default="host_observe",
show_default=True,
type=click.Choice(["read_only", "host_observe", "privileged_ops", "none"]),
help="Access surcharge for Kai (none = no surcharge)",
)
@click.option(
"--kai-amount",
type=int,
default=None,
help="Override Kai charge (skip catalog formula)",
)
@click.option(
"--no-metrics", is_flag=True, help="Skip .kaizen/metrics under engagement"
)
@click.option("--no-ledger", is_flag=True, help="Skip commercial/ledger.jsonl charge")
@click.option("--no-report", is_flag=True, help="Skip reports/ duty report")
@click.option("--idempotency-key", help="Skip metrics append if key already recorded")
@click.option("--repo-root", default=None, help="Repo root (default: auto-detect)")
@click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON result")
def engagement_close_session(
engagement_ref: str,
outcome_success: bool,
outcome_failure: bool,
duty: str,
summary: str,
execution_time: Optional[float],
quality: Optional[float],
access_class: str,
kai_amount: Optional[int],
no_metrics: bool,
no_ledger: bool,
no_report: bool,
idempotency_key: Optional[str],
repo_root: Optional[str],
as_json: bool,
):
"""Close a duty session: vault log, metrics, Kai ledger, optional report.
Does not invoke LLMs. Rejects summaries that look like secrets. Metrics land
under the engagement tree; ledger lines are billing metadata only.
"""
eng, _root = _resolve_eng(engagement_ref, repo_root)
if outcome_success and outcome_failure:
click.echo("Error: use only one of --success or --failure", err=True)
sys.exit(1)
if not outcome_success and not outcome_failure:
click.echo("Error: specify --success or --failure", err=True)
sys.exit(1)
if quality is not None and not (0.0 <= quality <= 1.0):
click.echo("Error: --quality must be between 0.0 and 1.0", err=True)
sys.exit(1)
access = None if access_class == "none" else access_class
try:
result = close_session(
eng,
success=outcome_success,
duty=duty,
summary=summary,
execution_time_s=execution_time,
quality=quality,
access_class=access,
amount_kai=kai_amount,
record_metrics=not no_metrics,
record_ledger=not no_ledger,
write_report=not no_report,
idempotency_key=idempotency_key,
)
except EngagementError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
except ValueError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
if as_json:
click.echo(json.dumps(result, indent=2))
return
click.echo(f"Closed session: {result['engagement_id']} ({result['duty']})")
click.echo(f" Memory: {result.get('memory_path')}")
if result.get("report_path"):
click.echo(f" Report: {result['report_path']}")
if result.get("metrics_recorded"):
click.echo(f" Metrics: recorded → {result.get('metrics_path')}")
elif not no_metrics:
click.echo(" Metrics: skipped (duplicate idempotency key)")
if not no_ledger:
click.echo(
f" Ledger: {result.get('amount_kai', 0)} Kai → {result.get('ledger_path')}"
)
@cli.group()
def schedule():
"""Prepare and validate scheduled agent runs (.kaizen/schedule.yml, ADR-005).

View file

@ -7,10 +7,11 @@ 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
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Pattern, Sequence, Tuple
@ -59,6 +60,22 @@ _SCRUB_PATTERNS: List[Tuple[str, Pattern]] = [
("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."""
@ -160,6 +177,60 @@ 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()
@ -483,10 +554,13 @@ def build_prepare_bundle(
"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",
(
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",
}
@ -867,3 +941,240 @@ def load_quote(eng: Engagement) -> Optional[Dict[str, Any]]:
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