csep 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. codex_self_evolution/__init__.py +3 -0
  2. codex_self_evolution/cli.py +554 -0
  3. codex_self_evolution/config.py +189 -0
  4. codex_self_evolution/config_file.py +528 -0
  5. codex_self_evolution/config_file_template.py +37 -0
  6. codex_self_evolution/csep.py +387 -0
  7. codex_self_evolution/diagnostics.py +266 -0
  8. codex_self_evolution/env_loader.py +109 -0
  9. codex_self_evolution/hooks/__init__.py +1 -0
  10. codex_self_evolution/hooks/session_start.py +94 -0
  11. codex_self_evolution/logging_setup.py +124 -0
  12. codex_self_evolution/migrate.py +334 -0
  13. codex_self_evolution/plugin_bundle/.codex-plugin/hooks.json +26 -0
  14. codex_self_evolution/plugin_bundle/.codex-plugin/plugin.json +34 -0
  15. codex_self_evolution/session_recall/__init__.py +16 -0
  16. codex_self_evolution/session_recall/archive.py +133 -0
  17. codex_self_evolution/session_recall/models.py +28 -0
  18. codex_self_evolution/session_recall/parser.py +170 -0
  19. codex_self_evolution/session_recall/policy.md +25 -0
  20. codex_self_evolution/session_recall/render.py +134 -0
  21. codex_self_evolution/session_recall/repo.py +53 -0
  22. codex_self_evolution/session_recall/session_recall.md +38 -0
  23. codex_self_evolution/session_recall/store.py +391 -0
  24. codex_self_evolution/session_recall/workflow.py +99 -0
  25. codex_self_evolution/session_reflection/__init__.py +1 -0
  26. codex_self_evolution/session_reflection/app_server.py +522 -0
  27. codex_self_evolution/session_reflection/guard.py +58 -0
  28. codex_self_evolution/session_reflection/models.py +15 -0
  29. codex_self_evolution/session_reflection/paths.py +72 -0
  30. codex_self_evolution/session_reflection/prompt.py +70 -0
  31. codex_self_evolution/session_reflection/runner.py +414 -0
  32. codex_self_evolution/session_reflection/state.py +330 -0
  33. codex_self_evolution/session_reflection/trigger.py +463 -0
  34. codex_self_evolution/session_reflection/validation.py +246 -0
  35. codex_self_evolution/skill_paths.py +16 -0
  36. codex_self_evolution/storage.py +89 -0
  37. csep-1.0.0.dist-info/METADATA +272 -0
  38. csep-1.0.0.dist-info/RECORD +42 -0
  39. csep-1.0.0.dist-info/WHEEL +5 -0
  40. csep-1.0.0.dist-info/entry_points.txt +3 -0
  41. csep-1.0.0.dist-info/licenses/LICENSE +21 -0
  42. csep-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,554 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .config_file import (
14
+ ConfigError,
15
+ config_to_dict,
16
+ get_config_path,
17
+ load_config,
18
+ )
19
+ from .config_file_template import CONFIG_TEMPLATE
20
+ from .diagnostics import collect_status
21
+ from .env_loader import hydrate_env_for_subprocesses
22
+ from .hooks.session_start import format_session_start_for_codex, session_start
23
+ from .logging_setup import configure as configure_logging, get_logger
24
+ from .migrate import run_migration
25
+ from .session_reflection.runner import (
26
+ enqueue_reflection_from_payload,
27
+ run_reflection_job,
28
+ session_reflection_status,
29
+ )
30
+
31
+
32
+ def build_parser(prog: str = "codex-self-evolution") -> argparse.ArgumentParser:
33
+ """Build the retained runtime parser under the requested console-script name."""
34
+ parser = argparse.ArgumentParser(prog=prog)
35
+ subparsers = parser.add_subparsers(dest="command", required=True)
36
+
37
+ session_parser = subparsers.add_parser("session-start")
38
+ # Required for manual/test invocation; ignored when --from-stdin reads cwd
39
+ # from the Codex hook payload.
40
+ session_parser.add_argument("--cwd")
41
+ session_parser.add_argument("--state-dir")
42
+ session_parser.add_argument(
43
+ "--from-stdin",
44
+ action="store_true",
45
+ help="Read a Codex SessionStart hook JSON payload from stdin, extract "
46
+ "cwd, build the stable-background bundle, and emit Codex "
47
+ "hookSpecificOutput JSON so the context is injected as "
48
+ "DeveloperInstructions in the model's session.",
49
+ )
50
+
51
+ stop_parser = subparsers.add_parser("session-stop")
52
+ stop_parser.add_argument("--state-dir")
53
+ stop_parser.add_argument(
54
+ "--from-stdin",
55
+ action="store_true",
56
+ help="Read a Codex native Stop hook JSON payload from stdin, archive the session, "
57
+ "evaluate the deterministic reflection trigger policy, enqueue a session-reflection "
58
+ 'job when needed, and emit {"continue": true}.',
59
+ )
60
+
61
+ reflect_parser = subparsers.add_parser("session-reflect")
62
+ reflect_mode = reflect_parser.add_mutually_exclusive_group(required=True)
63
+ reflect_mode.add_argument("--hook-payload")
64
+ reflect_mode.add_argument("--job")
65
+ reflect_mode.add_argument("--status", action="store_true")
66
+ reflect_parser.add_argument("--home")
67
+
68
+ status_parser = subparsers.add_parser(
69
+ "status",
70
+ help="Read-only diagnostic snapshot: which hooks are wired, whether "
71
+ "which API keys are set (reports names only, never values), CLI "
72
+ "tool versions, and per-bucket session state. Outputs JSON.",
73
+ )
74
+ status_parser.add_argument(
75
+ "--home",
76
+ help="Override CODEX_SELF_EVOLUTION_HOME (default ~/.codex-self-evolution).",
77
+ )
78
+
79
+ config_parser = subparsers.add_parser(
80
+ "config",
81
+ help="Inspect / initialise / validate ~/.codex-self-evolution/config.toml "
82
+ "— the single source of truth for retained session-level behavior.",
83
+ )
84
+ config_sub = config_parser.add_subparsers(dest="config_command", required=True)
85
+
86
+ config_show = config_sub.add_parser(
87
+ "show",
88
+ help="Print the fully-resolved configuration — including whether each "
89
+ "value came from config.toml or defaults.",
90
+ )
91
+ config_show.add_argument("--home")
92
+ config_show.add_argument(
93
+ "--raw",
94
+ action="store_true",
95
+ help="Print raw config.toml contents instead of the merged resolution.",
96
+ )
97
+
98
+ config_init = config_sub.add_parser(
99
+ "init",
100
+ help="Write a starter config.toml skeleton. Refuses to overwrite by default.",
101
+ )
102
+ config_init.add_argument("--home")
103
+ config_init.add_argument("--force", action="store_true",
104
+ help="Overwrite an existing config.toml.")
105
+
106
+ config_validate = config_sub.add_parser(
107
+ "validate",
108
+ help="Load + lint the current config. Exits 0 on clean, 1 on warnings, 2 on parse error.",
109
+ )
110
+ config_validate.add_argument("--home")
111
+
112
+ config_path = config_sub.add_parser(
113
+ "path",
114
+ help="Print the absolute path to config.toml (whether or not it exists).",
115
+ )
116
+ config_path.add_argument("--home")
117
+
118
+ migrate_parser = subparsers.add_parser(
119
+ "migrate-worktrees",
120
+ help="Consolidate buckets that belong to git worktrees of the same logical "
121
+ "repo. Without --apply runs in dry-run mode and prints the plan.",
122
+ )
123
+ migrate_parser.add_argument(
124
+ "--home",
125
+ help="Override CODEX_SELF_EVOLUTION_HOME for this invocation.",
126
+ )
127
+ migrate_parser.add_argument(
128
+ "--apply",
129
+ action="store_true",
130
+ help="Actually perform the migration. Without this flag the command "
131
+ "only prints the plan (dry-run).",
132
+ )
133
+
134
+ return parser
135
+
136
+
137
+ def _spawn_session_archive_from_stop_payload(
138
+ codex_payload: dict,
139
+ args: argparse.Namespace,
140
+ logger,
141
+ ) -> None:
142
+ """Spawn a detached session archive child from the raw Codex Stop payload."""
143
+ try:
144
+ session_recall = load_config().config.session_recall
145
+ except ConfigError as exc:
146
+ logger.warning(
147
+ "session recall config unavailable; skipping stop-hook archive",
148
+ extra={"kind": "session_recall_archive_skipped", "error": str(exc)[:400]},
149
+ )
150
+ return
151
+ if not session_recall.enabled or not session_recall.stop_hook_archive:
152
+ return
153
+
154
+ with tempfile.NamedTemporaryFile(
155
+ "w",
156
+ encoding="utf-8",
157
+ prefix="codex-self-evolution-session-archive-",
158
+ suffix=".json",
159
+ delete=False,
160
+ ) as handle:
161
+ json.dump(codex_payload, handle)
162
+ tmp_path = handle.name
163
+
164
+ child_argv = [
165
+ sys.executable,
166
+ "-m",
167
+ "codex_self_evolution.csep",
168
+ "session-archive",
169
+ "--from-hook-payload",
170
+ tmp_path,
171
+ "--cleanup-payload",
172
+ ]
173
+ if args.state_dir:
174
+ child_argv.extend(["--state-dir", args.state_dir])
175
+
176
+ log_dir = Path(tempfile.gettempdir()) / "codex-self-evolution"
177
+ log_dir.mkdir(exist_ok=True)
178
+ log_path = log_dir / f"session-archive-{os.getpid()}-{int(os.times()[4])}.log"
179
+ try:
180
+ log_handle = open(log_path, "w", encoding="utf-8")
181
+ except OSError:
182
+ log_handle = subprocess.DEVNULL # type: ignore[assignment]
183
+
184
+ try:
185
+ subprocess.Popen( # noqa: S603 — trusted argv
186
+ child_argv,
187
+ stdin=subprocess.DEVNULL,
188
+ stdout=log_handle,
189
+ stderr=subprocess.STDOUT,
190
+ start_new_session=True,
191
+ close_fds=True,
192
+ )
193
+ except OSError as exc:
194
+ if hasattr(log_handle, "close"):
195
+ log_handle.close()
196
+ try:
197
+ Path(tmp_path).unlink()
198
+ except OSError:
199
+ pass
200
+ logger.warning(
201
+ "failed to spawn session archive",
202
+ extra={"kind": "session_recall_archive_spawn_failed", "error": str(exc)[:400]},
203
+ )
204
+
205
+
206
+ def _handle_session_start_from_stdin(args: argparse.Namespace) -> int:
207
+ """Codex SessionStart hook entry point.
208
+
209
+ Codex sends a JSON payload on stdin (fields per
210
+ developers.openai.com/codex/hooks: session_id, transcript_path, cwd,
211
+ hook_event_name, model, source) and expects a JSON response on stdout
212
+ within the hook timeout. We return a Codex ``hookSpecificOutput`` shape
213
+ whose ``additionalContext`` text gets injected as ``DeveloperInstructions``
214
+ in the model session — verified against codex-cli 0.122.0.
215
+
216
+ Error discipline: this hook runs at session startup and must never block.
217
+ Any parse or runtime failure falls through to ``{"continue": true,
218
+ "warning": ...}`` so Codex can still start — worst case the user's
219
+ session just won't have the stable-background prefix injected.
220
+ """
221
+ try:
222
+ raw = sys.stdin.read()
223
+ codex_payload = json.loads(raw) if raw.strip() else {}
224
+ except json.JSONDecodeError as exc:
225
+ print(json.dumps({"continue": True, "warning": f"invalid codex payload: {exc}"}))
226
+ return 0
227
+
228
+ if not isinstance(codex_payload, dict):
229
+ print(json.dumps({"continue": True, "warning": "codex payload is not an object"}))
230
+ return 0
231
+
232
+ # Prefer the cwd Codex tells us about. Fall back to --cwd only for manual
233
+ # shell testing of the hook command outside a real Codex session.
234
+ cwd = codex_payload.get("cwd") if isinstance(codex_payload.get("cwd"), str) else None
235
+ if not cwd:
236
+ cwd = args.cwd
237
+ if not cwd:
238
+ print(json.dumps({"continue": True, "warning": "no cwd in codex payload or --cwd flag"}))
239
+ return 0
240
+
241
+ try:
242
+ session_result = session_start(cwd=cwd, state_dir=args.state_dir)
243
+ codex_output = format_session_start_for_codex(session_result)
244
+ except Exception as exc: # noqa: BLE001 — never block session startup
245
+ print(json.dumps({"continue": True, "warning": f"session_start failed: {exc}"}))
246
+ return 0
247
+
248
+ print(json.dumps(codex_output))
249
+ return 0
250
+
251
+
252
+ def _handle_session_stop_from_stdin(args: argparse.Namespace) -> int:
253
+ """Codex Stop hook entry point.
254
+
255
+ Codex will send a JSON object on stdin and expects a JSON response on
256
+ stdout within its per-hook timeout (typically 5-10s). We:
257
+
258
+ 1. Read + parse the Codex payload from stdin.
259
+ 2. Enqueue a session-reflection job using the raw Codex payload.
260
+ 3. Spawn a *detached* child process that re-invokes this CLI with
261
+ ``session-reflect --job <job_id>`` so app-server reflection runs in the
262
+ background.
263
+ 4. Print ``{"continue": true}`` and return immediately.
264
+
265
+ The child runs via ``sys.executable -m codex_self_evolution.cli`` to avoid
266
+ any dependency on ``uvx`` / PATH at runtime — whatever interpreter is
267
+ running this module can re-enter itself.
268
+ """
269
+ try:
270
+ raw = sys.stdin.read()
271
+ codex_payload = json.loads(raw) if raw.strip() else {}
272
+ except json.JSONDecodeError as exc:
273
+ print(json.dumps({"continue": True, "warning": f"invalid codex payload: {exc}"}))
274
+ return 0
275
+
276
+ if not isinstance(codex_payload, dict):
277
+ print(json.dumps({"continue": True, "warning": "codex payload is not an object"}))
278
+ return 0
279
+
280
+ _spawn_session_archive_from_stop_payload(codex_payload, args, get_logger())
281
+
282
+ try:
283
+ queued = enqueue_reflection_from_payload(codex_payload, home=args.state_dir)
284
+ except Exception as exc: # noqa: BLE001 — Stop hook must not block Codex.
285
+ print(json.dumps({"continue": True, "warning": f"failed to enqueue reflection job: {exc}"}))
286
+ return 0
287
+
288
+ job_id = queued.get("job_id")
289
+ if queued.get("status") != "queued" or not job_id:
290
+ print(json.dumps({"continue": True}))
291
+ return 0
292
+
293
+ child_argv = [
294
+ sys.executable,
295
+ "-m",
296
+ "codex_self_evolution.csep",
297
+ "session-reflect",
298
+ "--job",
299
+ str(job_id),
300
+ ]
301
+ if args.state_dir:
302
+ child_argv.extend(["--home", args.state_dir])
303
+
304
+ # Background reflection can fail silently (app server, provider, schema). Point
305
+ # stderr/stdout at a per-pid log file so we can post-mortem without turning
306
+ # the hook into a foreground blocker.
307
+ log_dir = Path("/tmp") / "codex-self-evolution"
308
+ log_dir.mkdir(exist_ok=True)
309
+ log_path = log_dir / f"session-reflect-{os.getpid()}-{int(os.times()[4])}.log"
310
+ try:
311
+ log_handle = open(log_path, "w", encoding="utf-8")
312
+ except OSError:
313
+ log_handle = subprocess.DEVNULL # type: ignore[assignment]
314
+
315
+ try:
316
+ subprocess.Popen( # noqa: S603 — trusted argv
317
+ child_argv,
318
+ stdin=subprocess.DEVNULL,
319
+ stdout=log_handle,
320
+ stderr=subprocess.STDOUT,
321
+ start_new_session=True,
322
+ close_fds=True,
323
+ )
324
+ except OSError as exc:
325
+ if hasattr(log_handle, "close"):
326
+ log_handle.close()
327
+ print(json.dumps({"continue": True, "warning": f"failed to spawn reflection worker: {exc}"}))
328
+ return 0
329
+
330
+ if hasattr(log_handle, "close"):
331
+ log_handle.close()
332
+ print(json.dumps({"continue": True}))
333
+ return 0
334
+
335
+
336
+ def _handle_session_reflect(args: argparse.Namespace) -> dict[str, Any]:
337
+ """Dispatch the session-reflection CLI modes and return JSON-serializable output."""
338
+ if args.job:
339
+ return run_reflection_job(args.job, home=args.home)
340
+ if args.status:
341
+ return session_reflection_status(home=args.home)
342
+
343
+ payload_path = Path(args.hook_payload)
344
+ payload = json.loads(payload_path.read_text(encoding="utf-8"))
345
+ if not isinstance(payload, dict):
346
+ raise ValueError("session-reflect --hook-payload must contain a JSON object")
347
+ queued = enqueue_reflection_from_payload(payload, home=args.home)
348
+ if queued.get("status") == "queued" and queued.get("job_id"):
349
+ return run_reflection_job(str(queued["job_id"]), home=args.home)
350
+ return queued
351
+
352
+
353
+ def main(argv: list[str] | None = None, *, prog: str = "codex-self-evolution") -> int:
354
+ """Run retained runtime commands for either the long or short entrypoint."""
355
+ parser = build_parser(prog=prog)
356
+ args = parser.parse_args(argv)
357
+
358
+ # Install the JSON-lines file logger before anything that might fail.
359
+ # Every main() invocation is a fresh short-lived process (hook or a
360
+ # user-typed command), so reconfiguring on entry is cheap and keeps
361
+ # test isolation tight — configure() also acts as a reset.
362
+ configure_logging()
363
+ logger = get_logger()
364
+
365
+ # Hydrate ~/.codex-self-evolution/.env.provider into os.environ so local
366
+ # subprocesses can find provider keys when launched by hooks.
367
+ hydrated = hydrate_env_for_subprocesses()
368
+ if hydrated:
369
+ # Values are NEVER logged; only the key names that just entered scope.
370
+ logger.info("env provider hydrated", extra={"kind": "env_hydrate", "keys": sorted(hydrated)})
371
+
372
+ started = time.monotonic()
373
+
374
+ try:
375
+ if args.command == "session-start":
376
+ if args.from_stdin:
377
+ exit_code = _handle_session_start_from_stdin(args)
378
+ _log_command(logger, args.command, started, exit_code=exit_code)
379
+ return exit_code
380
+ if not args.cwd:
381
+ parser.error("session-start requires --cwd or --from-stdin")
382
+ result = session_start(cwd=args.cwd, state_dir=args.state_dir)
383
+ elif args.command == "session-stop":
384
+ if args.from_stdin:
385
+ exit_code = _handle_session_stop_from_stdin(args)
386
+ _log_command(logger, args.command, started, exit_code=exit_code, mode="from_stdin")
387
+ return exit_code
388
+ parser.error("session-stop requires --from-stdin")
389
+ elif args.command == "session-reflect":
390
+ result = _handle_session_reflect(args)
391
+ elif args.command == "status":
392
+ result = collect_status(home=args.home)
393
+ elif args.command == "migrate-worktrees":
394
+ result = run_migration(
395
+ home=Path(args.home).expanduser().resolve() if args.home else None,
396
+ apply=args.apply,
397
+ )
398
+ elif args.command == "config":
399
+ result = _handle_config_subcommand(args)
400
+ if result.get("_exit_code") is not None:
401
+ exit_code = result.pop("_exit_code")
402
+ print(json.dumps(result, indent=2, sort_keys=True))
403
+ _log_command(logger, args.command, started, exit_code=exit_code,
404
+ subcommand=args.config_command)
405
+ return exit_code
406
+ else:
407
+ parser.error(f"unknown command: {args.command}")
408
+ return 2
409
+
410
+ print(json.dumps(result, indent=2, sort_keys=True))
411
+ log_extras = _observability_extras(args.command, result)
412
+ _log_command(logger, args.command, started, exit_code=0, **log_extras)
413
+ return 0
414
+ except SystemExit:
415
+ # argparse calls sys.exit(2) for bad args; re-raise so the user still
416
+ # sees the usage message. Don't bother logging — argparse already
417
+ # printed to stderr and nothing interesting ran.
418
+ raise
419
+ except Exception as exc: # noqa: BLE001 — log everything, let caller decide
420
+ # Record the failure before re-raising so the log captures what the
421
+ # user won't see in their terminal (if stderr was piped somewhere).
422
+ _log_command(
423
+ logger, args.command, started,
424
+ exit_code=1,
425
+ error_type=type(exc).__name__,
426
+ error_message=str(exc)[:400],
427
+ )
428
+ raise
429
+
430
+
431
+ def _handle_config_subcommand(args: argparse.Namespace) -> dict:
432
+ """Dispatcher for retained config subcommands.
433
+
434
+ Returns a dict; callers check for ``_exit_code`` to handle non-zero
435
+ exit paths (validate warnings, migrate no-ops, etc.) uniformly.
436
+ """
437
+ home = Path(args.home).expanduser().resolve() if args.home else None
438
+ if args.config_command == "path":
439
+ return {"_exit_code": 0, "config_path": str(get_config_path(home))}
440
+
441
+ if args.config_command == "init":
442
+ path = get_config_path(home)
443
+ existed_before = path.exists()
444
+ if existed_before and not args.force:
445
+ return {
446
+ "_exit_code": 1,
447
+ "status": "exists",
448
+ "config_path": str(path),
449
+ "error": "config.toml already exists; use --force to overwrite",
450
+ }
451
+ path.parent.mkdir(parents=True, exist_ok=True)
452
+ path.write_text(CONFIG_TEMPLATE, encoding="utf-8")
453
+ return {
454
+ "_exit_code": 0,
455
+ "status": "overwritten" if existed_before else "created",
456
+ "config_path": str(path),
457
+ }
458
+
459
+ if args.config_command == "show":
460
+ if args.raw:
461
+ path = get_config_path(home)
462
+ if path.is_file():
463
+ return {"_exit_code": 0, "config_path": str(path),
464
+ "config_exists": True, "raw": path.read_text(encoding="utf-8")}
465
+ return {"_exit_code": 0, "config_path": str(path),
466
+ "config_exists": False, "raw": ""}
467
+ try:
468
+ loaded = load_config(home=home)
469
+ except ConfigError as exc:
470
+ return {"_exit_code": 2, "status": "parse_error", "error": str(exc)}
471
+ env_provider_view = _env_provider_api_key_summary(home)
472
+ return {
473
+ "_exit_code": 0,
474
+ "config_path": str(loaded.config_path),
475
+ "config_exists": loaded.config_exists,
476
+ "schema_version": loaded.config.schema_version,
477
+ "resolved": config_to_dict(loaded.config),
478
+ "sources": loaded.sources,
479
+ "warnings": loaded.warnings,
480
+ "env_provider": env_provider_view,
481
+ }
482
+
483
+ if args.config_command == "validate":
484
+ try:
485
+ loaded = load_config(home=home)
486
+ except ConfigError as exc:
487
+ return {"_exit_code": 2, "status": "parse_error", "error": str(exc)}
488
+ exit_code = 1 if loaded.warnings else 0
489
+ return {
490
+ "_exit_code": exit_code,
491
+ "status": "ok" if exit_code == 0 else "warnings",
492
+ "config_path": str(loaded.config_path),
493
+ "config_exists": loaded.config_exists,
494
+ "warnings": loaded.warnings,
495
+ }
496
+
497
+ return {"_exit_code": 2, "error": f"unknown config subcommand: {args.config_command}"}
498
+
499
+
500
+ def _env_provider_api_key_summary(home: Path | None) -> dict:
501
+ """Show API key presence (never values) for `config show` output."""
502
+ from .diagnostics import _check_env_provider
503
+ from .config import get_home_dir
504
+
505
+ home_dir = home or get_home_dir()
506
+ data = _check_env_provider(home_dir)
507
+ return {
508
+ "path": data["path"],
509
+ "exists": data["exists"],
510
+ "keys_set": data["keys_set"],
511
+ "keys_unset": data["keys_unset"],
512
+ "other_keys_set": data["other_keys_set"],
513
+ }
514
+ def _observability_extras(command: str | None, result: object) -> dict:
515
+ """Return compact per-command log extras for retained commands only."""
516
+ if not isinstance(result, dict):
517
+ return {}
518
+ if command == "session-stop":
519
+ return {"mode": "from_stdin"}
520
+ if command == "session-reflect":
521
+ status = result.get("status")
522
+ job_id = result.get("job_id")
523
+ extras: dict[str, object] = {}
524
+ if status:
525
+ extras["status"] = status
526
+ if job_id:
527
+ extras["job_id"] = job_id
528
+ return extras
529
+ return {}
530
+
531
+
532
+ def _log_command(
533
+ logger, command: str, started: float, *, exit_code: int, **extras,
534
+ ) -> None:
535
+ """Emit one structured summary line per CLI invocation.
536
+
537
+ Called at the boundary of ``main()`` so each hook or manual CLI call
538
+ leaves exactly one record behind. Start with the boundary and push inward
539
+ only if an investigation actually needs it.
540
+ """
541
+ duration_ms = int((time.monotonic() - started) * 1000)
542
+ logger.info(
543
+ "cli command completed",
544
+ extra={
545
+ "kind": command or "unknown",
546
+ "exit_code": exit_code,
547
+ "duration_ms": duration_ms,
548
+ **extras,
549
+ },
550
+ )
551
+
552
+
553
+ if __name__ == "__main__":
554
+ raise SystemExit(main())