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
|
|
@ -12,9 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
`keepaTodofile` agent remains available for other projects
|
||||
|
||||
### Added
|
||||
- **`engagement` CLI (WP-0009 T08)** — forward-deployed engagement lifecycle:
|
||||
- **`engagement` CLI (WP-0009 T08–T09)** — forward-deployed engagement lifecycle:
|
||||
`list`/`show`/`validate`/`checklist`/`phase`/`prepare`/`staff`/`quote`/
|
||||
`scrub`/`export-handoff` over file-based pilots (`engagements/pilots/`)
|
||||
`scrub`/`export-handoff`/`close-session` over file-based pilots
|
||||
(`engagements/pilots/`); close-session wires vault log, metrics, and Kai ledger
|
||||
- **`metrics record --emit-event`** — publishes `kaizen.metrics.recorded` NATS
|
||||
envelope for activity-core event-driven definitions (optional `nats-py` via
|
||||
`pip install 'kaizen-agentic[events]'`)
|
||||
|
|
|
|||
|
|
@ -144,6 +144,13 @@ kaizen-agentic engagement phase eng-… --to operating --force # override grap
|
|||
kaizen-agentic engagement prepare eng-coulomb-railiance01-ho-001
|
||||
kaizen-agentic engagement prepare eng-… --format json
|
||||
|
||||
# Session close — vault log + metrics + Kai ledger + report (no secrets)
|
||||
kaizen-agentic engagement close-session eng-coulomb-railiance01-ho-001 \
|
||||
--success --duty standard_review \
|
||||
--summary "daily health: watch-level disk, load OK" \
|
||||
--time 120 --quality 0.85
|
||||
# Flags: --no-metrics --no-ledger --no-report --kai-amount N --access-class host_observe
|
||||
|
||||
# Staff a new pilot from a Role package
|
||||
kaizen-agentic engagement staff \
|
||||
--id eng-example-001 --role host-operator \
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
{"agent": "host-operator", "duty": "standard_review", "engagement_id": "eng-coulomb-railiance01-ho-001", "execution_time_s": 5.0, "phase": "staffing", "quality_score": 0.7, "success": true, "timestamp": "2026-07-16T10:08:57Z"}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"agent": "host-operator",
|
||||
"avg_execution_time_s": 5.0,
|
||||
"avg_quality_score": 0.7,
|
||||
"execution_count": 1,
|
||||
"last_execution": "2026-07-16T10:08:57Z",
|
||||
"success_rate": 1.0,
|
||||
"trend": {
|
||||
"quality_score": "stable",
|
||||
"success_rate": "stable"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
{"apiVersion":"kaizen.agentic/v1","kind":"KaiLedgerEntry","id":"kai-20260716-quote-open","account":"coulomb-ops-kai","engagement_id":"eng-coulomb-railiance01-ho-001","type":"note","product":"quote_snapshot","capability_tier":4,"amount_kai":0,"currency":"KAI","created_at":"2026-07-16T08:00:00Z","metadata":{"total_quoted_kai":72800,"phase":"staffing","note":"Month-1 estimate recorded; no charge until fund/ramp"}}
|
||||
{"access_surcharge_product": "read_only", "account": "coulomb-ops-kai", "amount_kai": 1600, "apiVersion": "kaizen.agentic/v1", "capability_tier": 4, "created_at": "2026-07-16T10:08:57Z", "currency": "KAI", "engagement_id": "eng-coulomb-railiance01-ho-001", "id": "kai-20260716T100857Z-standard_review", "kind": "KaiLedgerEntry", "metadata": {"access_class": "read_only", "phase": "staffing", "success": true, "target": "railiance01"}, "product": "standard_review", "session_ref": "reports/2026-07-16-standard-review.md", "type": "duty_charge"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
# Session report — eng-coulomb-railiance01-ho-001
|
||||
|
||||
- **Date:** 2026-07-16
|
||||
- **Duty:** standard_review
|
||||
- **Targets:** railiance01
|
||||
- **Outcome:** success
|
||||
- **Phase:** staffing
|
||||
|
||||
## Summary
|
||||
|
||||
T09 wire-up smoke: prepare+close-session path verified (no host access)
|
||||
|
||||
_Billing metadata only in commercial/ledger.jsonl; no secrets in this report._
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
agent: host-operator
|
||||
engagement_id: eng-coulomb-railiance01-ho-001
|
||||
project: coulomb-railiance01
|
||||
last_updated: "2026-07-16"
|
||||
session_count: 0
|
||||
last_updated: '2026-07-16'
|
||||
session_count: 1
|
||||
confidentiality: client_owned
|
||||
---
|
||||
|
||||
|
|
@ -72,3 +72,4 @@ _None yet._
|
|||
## Session Log
|
||||
|
||||
<!-- YYYY-MM-DD · host(s) · key finding · outcome -->
|
||||
- 2026-07-16 · railiance01 · standard_review · T09 wire-up smoke: prepare+close-session path verified (no host access) · ok
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@ from kaizen_agentic.engagement import (
|
|||
EngagementError,
|
||||
build_prepare_bundle,
|
||||
checklist_summary,
|
||||
close_session,
|
||||
compute_duty_kai,
|
||||
load_checklist,
|
||||
load_engagement,
|
||||
parse_checklist_markdown,
|
||||
set_checklist_item_status,
|
||||
set_phase,
|
||||
staff_engagement,
|
||||
text_has_sensitive_content,
|
||||
validate_engagement,
|
||||
)
|
||||
|
||||
|
|
@ -159,6 +162,71 @@ class TestStaffAndLifecycle:
|
|||
assert eng.phase == "closed"
|
||||
|
||||
|
||||
class TestCloseSession:
|
||||
def test_compute_duty_kai(self):
|
||||
assert (
|
||||
compute_duty_kai("standard_review", tier=4, access_class="host_observe")
|
||||
== 1700
|
||||
)
|
||||
assert compute_duty_kai("ramp_up_package", tier=4) == 10000
|
||||
assert compute_duty_kai("short_assist", tier=1, amount_override=50) == 50
|
||||
|
||||
def test_sensitive_summary_rejected(self, mini_repo: Path):
|
||||
eng = staff_engagement(
|
||||
engagement_id="eng-close-sens",
|
||||
role_id="host-operator",
|
||||
client_id="c",
|
||||
target_id="h1",
|
||||
repo_root=mini_repo,
|
||||
)
|
||||
with pytest.raises(EngagementError, match="sensitive"):
|
||||
close_session(
|
||||
eng,
|
||||
success=True,
|
||||
summary="password: hunter2 leaked",
|
||||
record_metrics=False,
|
||||
record_ledger=False,
|
||||
write_report=False,
|
||||
)
|
||||
assert text_has_sensitive_content("-----BEGIN RSA PRIVATE KEY-----")
|
||||
|
||||
def test_close_session_writes_all_artifacts(self, mini_repo: Path):
|
||||
eng = staff_engagement(
|
||||
engagement_id="eng-close-001",
|
||||
role_id="host-operator",
|
||||
client_id="coulomb",
|
||||
target_id="railiance01",
|
||||
repo_root=mini_repo,
|
||||
)
|
||||
result = close_session(
|
||||
eng,
|
||||
success=True,
|
||||
duty="standard_review",
|
||||
summary="first health pass watch-level disk",
|
||||
execution_time_s=90.0,
|
||||
quality=0.85,
|
||||
access_class="host_observe",
|
||||
)
|
||||
assert result["amount_kai"] == 1700
|
||||
assert result["metrics_recorded"] is True
|
||||
mem = Path(result["memory_path"]).read_text(encoding="utf-8")
|
||||
assert "first health pass" in mem
|
||||
assert "session_count: 1" in mem or "session_count: 1" in mem.replace('"', "")
|
||||
report = Path(result["report_path"])
|
||||
assert report.exists()
|
||||
assert "first health pass" in report.read_text(encoding="utf-8")
|
||||
ledger = (
|
||||
Path(result["ledger_path"]).read_text(encoding="utf-8").strip().splitlines()
|
||||
)
|
||||
last = json.loads(ledger[-1])
|
||||
assert last["amount_kai"] == 1700
|
||||
assert last["product"] == "standard_review"
|
||||
assert "password" not in last
|
||||
metrics_path = Path(result["metrics_path"])
|
||||
assert metrics_path.exists()
|
||||
assert "engagement_id" in metrics_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestEngagementCli:
|
||||
def test_staff_list_checklist_prepare(self, runner: CliRunner, mini_repo: Path):
|
||||
result = runner.invoke(
|
||||
|
|
@ -363,3 +431,46 @@ class TestEngagementCli:
|
|||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "72800" in result.output or "Kai" in result.output
|
||||
|
||||
def test_close_session_cli(self, runner: CliRunner, mini_repo: Path):
|
||||
runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"engagement",
|
||||
"staff",
|
||||
"--id",
|
||||
"eng-close-cli",
|
||||
"--role",
|
||||
"host-operator",
|
||||
"--client",
|
||||
"coulomb",
|
||||
"--target",
|
||||
"railiance01",
|
||||
"--repo-root",
|
||||
str(mini_repo),
|
||||
],
|
||||
)
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"engagement",
|
||||
"close-session",
|
||||
"eng-close-cli",
|
||||
"--success",
|
||||
"--duty",
|
||||
"standard_review",
|
||||
"--summary",
|
||||
"load review healthy",
|
||||
"--time",
|
||||
"60",
|
||||
"--quality",
|
||||
"0.9",
|
||||
"--repo-root",
|
||||
str(mini_repo),
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.output)
|
||||
assert data["amount_kai"] == 1700
|
||||
assert data["metrics_recorded"] is True
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ tasks:
|
|||
status: done
|
||||
title: CLI engagement command group (request, staff, prepare, checklist, scrub)
|
||||
- id: T09
|
||||
status: todo
|
||||
status: done
|
||||
title: Wire prepare/session-close to vault paths, metrics, and ledger entries
|
||||
- id: T10
|
||||
status: todo
|
||||
|
|
@ -202,14 +202,15 @@ Tests: `tests/test_engagement_cli.py`. Docs: CLI cheat sheet section.
|
|||
|
||||
```task
|
||||
id: KAIZEN-WP-0009-T09
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "82ac0b5a-0309-4912-93bf-f05bee2abbc2"
|
||||
```
|
||||
|
||||
- `engagement prepare` bundles definition + vault + protocols + access plan
|
||||
- Session close path updates vault, `metrics record`, optional Kai duty charge
|
||||
- No secrets in prepare output or ledger
|
||||
**Delivered:** `engagement close-session` wires vault session log, engagement-scoped
|
||||
metrics (`.kaizen/metrics/` under the pilot tree), Kai `ledger.jsonl` duty charge,
|
||||
and `reports/` stub. Rejects sensitive summaries. `prepare` session-close section
|
||||
points at close-session. Tests cover formula, scrub, and CLI.
|
||||
|
||||
## railiance01 pilot through ramp-up
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue