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 `keepaTodofile` agent remains available for other projects
### Added ### 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`/ `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 - **`metrics record --emit-event`** — publishes `kaizen.metrics.recorded` NATS
envelope for activity-core event-driven definitions (optional `nats-py` via envelope for activity-core event-driven definitions (optional `nats-py` via
`pip install 'kaizen-agentic[events]'`) `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-coulomb-railiance01-ho-001
kaizen-agentic engagement prepare eng-… --format json 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 # Staff a new pilot from a Role package
kaizen-agentic engagement staff \ kaizen-agentic engagement staff \
--id eng-example-001 --role host-operator \ --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"}} {"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 agent: host-operator
engagement_id: eng-coulomb-railiance01-ho-001 engagement_id: eng-coulomb-railiance01-ho-001
project: coulomb-railiance01 project: coulomb-railiance01
last_updated: "2026-07-16" last_updated: '2026-07-16'
session_count: 0 session_count: 1
confidentiality: client_owned confidentiality: client_owned
--- ---
@ -72,3 +72,4 @@ _None yet._
## Session Log ## Session Log
<!-- YYYY-MM-DD · host(s) · key finding · outcome --> <!-- 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 .optimization import OptimizationLoop, MIN_SAMPLES_FOR_RECOMMENDATIONS
from .engagement_promote import promote_engagement from .engagement_promote import promote_engagement
from .engagement import ( from .engagement import (
DUTY_BASE_KAI,
EngagementError, EngagementError,
build_prepare_bundle, build_prepare_bundle,
close_session,
export_handoff, export_handoff,
find_repo_root, find_repo_root,
list_engagement_dirs, list_engagement_dirs,
@ -1797,6 +1799,119 @@ def engagement_export_handoff(engagement_ref: str, repo_root: Optional[str]):
click.echo(f"Handoff pack: {handoff}") 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() @cli.group()
def schedule(): def schedule():
"""Prepare and validate scheduled agent runs (.kaizen/schedule.yml, ADR-005). """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 from __future__ import annotations
import json
import re import re
import shutil import shutil
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Pattern, Sequence, Tuple 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+")), ("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): class EngagementError(Exception):
"""Raised when an engagement cannot be loaded or operated on.""" """Raised when an engagement cannot be loaded or operated on."""
@ -160,6 +177,60 @@ def today_iso() -> str:
return date.today().isoformat() 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: def find_repo_root(start: Optional[Path] = None) -> Path:
"""Walk parents for engagements/ or roles/ or .git.""" """Walk parents for engagements/ or roles/ or .git."""
cur = (start or Path.cwd()).resolve() cur = (start or Path.cwd()).resolve()
@ -483,10 +554,13 @@ def build_prepare_bundle(
"checklist_ramp_down": checklist_summary(ramp_down_items), "checklist_ramp_down": checklist_summary(ramp_down_items),
"validation_errors": validate_engagement(eng), "validation_errors": validate_engagement(eng),
"session_close": [ "session_close": [
"Update vault memory (session log, findings, envelope)", (
"Write reports/<date>-*.md for the duty", f"kaizen-agentic engagement close-session {eng.engagement_id} "
"Append commercial/ledger.jsonl Kai duty charge (no secrets)", "--success --duty standard_review --summary '<one-line outcome>' "
"kaizen-agentic engagement checklist <id> # refresh status", "--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", "confidentiality": "client_owned",
} }
@ -867,3 +941,240 @@ def load_quote(eng: Engagement) -> Optional[Dict[str, Any]]:
except yaml.YAMLError as exc: except yaml.YAMLError as exc:
raise EngagementError(f"Invalid quote YAML: {exc}") from exc raise EngagementError(f"Invalid quote YAML: {exc}") from exc
return data if isinstance(data, dict) else None 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, EngagementError,
build_prepare_bundle, build_prepare_bundle,
checklist_summary, checklist_summary,
close_session,
compute_duty_kai,
load_checklist, load_checklist,
load_engagement, load_engagement,
parse_checklist_markdown, parse_checklist_markdown,
set_checklist_item_status, set_checklist_item_status,
set_phase, set_phase,
staff_engagement, staff_engagement,
text_has_sensitive_content,
validate_engagement, validate_engagement,
) )
@ -159,6 +162,71 @@ class TestStaffAndLifecycle:
assert eng.phase == "closed" 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: class TestEngagementCli:
def test_staff_list_checklist_prepare(self, runner: CliRunner, mini_repo: Path): def test_staff_list_checklist_prepare(self, runner: CliRunner, mini_repo: Path):
result = runner.invoke( result = runner.invoke(
@ -363,3 +431,46 @@ class TestEngagementCli:
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert "72800" in result.output or "Kai" in 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 status: done
title: CLI engagement command group (request, staff, prepare, checklist, scrub) title: CLI engagement command group (request, staff, prepare, checklist, scrub)
- id: T09 - id: T09
status: todo status: done
title: Wire prepare/session-close to vault paths, metrics, and ledger entries title: Wire prepare/session-close to vault paths, metrics, and ledger entries
- id: T10 - id: T10
status: todo status: todo
@ -202,14 +202,15 @@ Tests: `tests/test_engagement_cli.py`. Docs: CLI cheat sheet section.
```task ```task
id: KAIZEN-WP-0009-T09 id: KAIZEN-WP-0009-T09
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "82ac0b5a-0309-4912-93bf-f05bee2abbc2" state_hub_task_id: "82ac0b5a-0309-4912-93bf-f05bee2abbc2"
``` ```
- `engagement prepare` bundles definition + vault + protocols + access plan **Delivered:** `engagement close-session` wires vault session log, engagement-scoped
- Session close path updates vault, `metrics record`, optional Kai duty charge metrics (`.kaizen/metrics/` under the pilot tree), Kai `ledger.jsonl` duty charge,
- No secrets in prepare output or ledger 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 ## railiance01 pilot through ramp-up