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

@ -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 T08T09)** — 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]'`)

View file

@ -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 \

View file

@ -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"}

View file

@ -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"
}
}

View file

@ -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"}

View file

@ -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._

View file

@ -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

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

View file

@ -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

View file

@ -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