From 45321965460efa3a5430af5d16a1ec8d480f78c5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 16 Jul 2026 12:09:05 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 5 +- docs/CLI_CHEAT_SHEET.md | 7 + .../metrics/host-operator/executions.jsonl | 1 + .../metrics/host-operator/summary.json | 12 + .../commercial/ledger.jsonl | 1 + .../reports/2026-07-16-standard-review.md | 13 + .../vault/memory.md | 5 +- src/kaizen_agentic/cli.py | 115 +++++++ src/kaizen_agentic/engagement.py | 321 +++++++++++++++++- tests/test_engagement_cli.py | 111 ++++++ ...agentic-WP-0009-forward-deployed-agency.md | 11 +- 11 files changed, 588 insertions(+), 14 deletions(-) create mode 100644 engagements/pilots/eng-coulomb-railiance01-ho-001/.kaizen/metrics/host-operator/executions.jsonl create mode 100644 engagements/pilots/eng-coulomb-railiance01-ho-001/.kaizen/metrics/host-operator/summary.json create mode 100644 engagements/pilots/eng-coulomb-railiance01-ho-001/reports/2026-07-16-standard-review.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 986385e..1a3dd15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]'`) diff --git a/docs/CLI_CHEAT_SHEET.md b/docs/CLI_CHEAT_SHEET.md index b2d88ac..b413e11 100644 --- a/docs/CLI_CHEAT_SHEET.md +++ b/docs/CLI_CHEAT_SHEET.md @@ -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 \ diff --git a/engagements/pilots/eng-coulomb-railiance01-ho-001/.kaizen/metrics/host-operator/executions.jsonl b/engagements/pilots/eng-coulomb-railiance01-ho-001/.kaizen/metrics/host-operator/executions.jsonl new file mode 100644 index 0000000..d7e011a --- /dev/null +++ b/engagements/pilots/eng-coulomb-railiance01-ho-001/.kaizen/metrics/host-operator/executions.jsonl @@ -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"} diff --git a/engagements/pilots/eng-coulomb-railiance01-ho-001/.kaizen/metrics/host-operator/summary.json b/engagements/pilots/eng-coulomb-railiance01-ho-001/.kaizen/metrics/host-operator/summary.json new file mode 100644 index 0000000..2443a7f --- /dev/null +++ b/engagements/pilots/eng-coulomb-railiance01-ho-001/.kaizen/metrics/host-operator/summary.json @@ -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" + } +} diff --git a/engagements/pilots/eng-coulomb-railiance01-ho-001/commercial/ledger.jsonl b/engagements/pilots/eng-coulomb-railiance01-ho-001/commercial/ledger.jsonl index a546024..251d99e 100644 --- a/engagements/pilots/eng-coulomb-railiance01-ho-001/commercial/ledger.jsonl +++ b/engagements/pilots/eng-coulomb-railiance01-ho-001/commercial/ledger.jsonl @@ -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"} diff --git a/engagements/pilots/eng-coulomb-railiance01-ho-001/reports/2026-07-16-standard-review.md b/engagements/pilots/eng-coulomb-railiance01-ho-001/reports/2026-07-16-standard-review.md new file mode 100644 index 0000000..9ff9d2a --- /dev/null +++ b/engagements/pilots/eng-coulomb-railiance01-ho-001/reports/2026-07-16-standard-review.md @@ -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._ diff --git a/engagements/pilots/eng-coulomb-railiance01-ho-001/vault/memory.md b/engagements/pilots/eng-coulomb-railiance01-ho-001/vault/memory.md index bd438fb..2ade042 100644 --- a/engagements/pilots/eng-coulomb-railiance01-ho-001/vault/memory.md +++ b/engagements/pilots/eng-coulomb-railiance01-ho-001/vault/memory.md @@ -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 +- 2026-07-16 · railiance01 · standard_review · T09 wire-up smoke: prepare+close-session path verified (no host access) · ok diff --git a/src/kaizen_agentic/cli.py b/src/kaizen_agentic/cli.py index 8135480..8c54f06 100644 --- a/src/kaizen_agentic/cli.py +++ b/src/kaizen_agentic/cli.py @@ -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). diff --git a/src/kaizen_agentic/engagement.py b/src/kaizen_agentic/engagement.py index 39089a2..79a23ef 100644 --- a/src/kaizen_agentic/engagement.py +++ b/src/kaizen_agentic/engagement.py @@ -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/-*.md for the duty", - "Append commercial/ledger.jsonl Kai duty charge (no secrets)", - "kaizen-agentic engagement checklist # refresh status", + ( + f"kaizen-agentic engagement close-session {eng.engagement_id} " + "--success --duty standard_review --summary '' " + "--time --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//``) + 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 diff --git a/tests/test_engagement_cli.py b/tests/test_engagement_cli.py index 718437b..d41e61b 100644 --- a/tests/test_engagement_cli.py +++ b/tests/test_engagement_cli.py @@ -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 diff --git a/workplans/kaizen-agentic-WP-0009-forward-deployed-agency.md b/workplans/kaizen-agentic-WP-0009-forward-deployed-agency.md index 19056f8..3783f95 100644 --- a/workplans/kaizen-agentic-WP-0009-forward-deployed-agency.md +++ b/workplans/kaizen-agentic-WP-0009-forward-deployed-agency.md @@ -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