feat: engagement close-session wires vault, metrics, Kai ledger (WP-0009 T09)
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:
parent
ad5965be91
commit
4532196546
11 changed files with 588 additions and 14 deletions
|
|
@ -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.0–1.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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue