6The session miner — what actually happened in a plan's dogfood sessions.
10Worker sessions produce friction evidence — errors, denials, retry loops,
11truncations, workarounds — that evaporates when the session ends. This module
12mines it mechanically, per the ratified design
13``docs/architecture/AGENT_DISPATCH_IDENTITY_DESIGN_2026-08-26.md`` §4.7: it
14reads the plan's oc_chat dogfood sessions (session-store content — turns, tool
15calls and their outcomes — plus the event streams) and emits a per-plan
16findings pair at ``docs/reports/postmortems/<NNN>-postmortem.md`` and
17``<NNN>-postmortem.json``.
19The miner never concludes and never files anything. Findings become backlog
20items only through operator decision at the gate (§4.7, design decision D4);
21nothing here edits roadmap source, a plan, or any document beyond its own two
26- :func:`discover_sessions` — resolve which sessions belong to the plan. An
27 explicit ``--session`` list takes precedence; otherwise the store is searched
28 for sessions whose directory name, ``session_name`` or ``active_plan_id``
29 references the plan id. What was searched and what matched is recorded in the
30 output, so a miss is legible rather than silent.
31- :func:`mine_session` — extract findings in the eight pinned categories
32 (:data:`CATEGORIES`) from the chat log and the event stream.
33- :func:`build_report` / :func:`render_json` / :func:`render_markdown` — the
34 two emitters. They carry identical findings and their totals agree by
35 construction: the markdown counts are read off the same ``by_category`` map
37- :func:`main` — the ``python -m oc_chat.scripts.session_postmortem <NNN>`` CLI.
39Read-only contract (the plan-212 miner's rule)
40----------------------------------------------
41Every input is opened for reading only. The module deliberately does NOT use
42:mod:`oc_chat.core.persistence`'s directory helpers (``_sessions_root``,
43``_session_dir``, ``_archive_dir``) because those call ``_ensure_dir`` and so
44bring a directory into being as a side effect of asking about it; the local
45:func:`_latest_kind` mirrors ``_glob_latest`` without that. The ONLY writes are
46the two postmortem files, each written via a temp file plus ``os.replace`` so a
47crash mid-emit cannot leave a torn report — and cannot touch an input either
53 Shared with the offline replay reader deliberately: this is the same
54 read-only-analysis-over-the-event-substrate surface, and the domain table in
55 ``.option_c/debug_config.json`` gates on exact literals — an unlisted domain
56 is silently threshold 0, and that file is outside this plan's scope.
58 L2 — CLI invocation, discovery outcome, emit.
59 L3 — per-session substrate detail.
60 L4 — module-load coverage marker.
64- Exit 0 on success including the empty case (no sessions found still emits an
65 honest postmortem naming what was searched); 2 on user error; 1 unexpected.
66- Every category in :data:`CATEGORIES` appears in both outputs, zero included —
67 an empty section is information, not omission.
68- Output carries no wall-clock stamp, so re-running over unchanged inputs
69 produces byte-identical files (a mined report is a function of its inputs).
70- Findings are lenses, not a partition: one message can raise findings in more
71 than one category, but each (locus, signal) raises at most one finding, under
72 the precedence documented on each detector.
76- oc_diagnostics (structured debug logging via _dbg)
77- oc_chat.core.event_bootstrap (_slug / _contained_alias_dir — reused rather
78 than copied because alias validation and root containment are security-
79 relevant path handling that must not drift)
80- oc_chat.core.replay (read_events_from_dir — the tolerant NDJSON slot reader)
81- oc_chat.core.persistence (DEFAULT_SESSIONS_DIR and the reserved dir names)
83from __future__
import annotations
91from dataclasses
import dataclass, field
92from datetime
import datetime
93from pathlib
import Path
94from typing
import Any, Optional, Sequence
96from oc_diagnostics
import _dbg
101 DEFAULT_SESSIONS_DIR,
106_dbg(
"OC_CHAT.REPLAY",
"session_postmortem",
"module", 4)
109SCHEMA_VERSION =
"postmortem/v1"
114CATEGORIES: tuple[str, ...] = (
117 "tool_call_failures_and_denials",
120 "missing_tool_workarounds",
126CATEGORY_TITLES: dict[str, str] = {
128 "warnings":
"Warnings",
129 "tool_call_failures_and_denials":
"Tool-call failures and denials",
130 "truncations":
"Truncations",
131 "retry_loops":
"Retry loops",
132 "missing_tool_workarounds":
"Missing-tool workarounds",
133 "cwd_anchor_drift":
"cwd / anchor drift",
134 "blocked_time_gaps":
"Blocked-time gaps",
140PLAN_ID_RE = re.compile(
r"^[0-9]{1,4}[a-z]?$")
144EXCERPT_MAX_CHARS = 200
148DEFAULT_GAP_SECONDS = 600.0
155_OK_OUTCOMES = frozenset({
"ok",
"success"})
158_DENY_DECISIONS = frozenset({
"deny",
"denied",
"reject",
"rejected"})
163_TRUNCATION_MARKERS: tuple[str, ...] = (
164 "[output truncated at",
165 "[assistant reply truncated for API context",
167 "[Tool call(s) truncated",
168 "[File truncated at limit]",
169 "[File was truncated at read limit]",
170 "[truncated for summariser]",
176_DRIFT_MARKERS: tuple[str, ...] = (
177 "No such file or directory",
178 "The system cannot find the file specified",
179 "The system cannot find the path specified",
180 "cannot find the path",
181 "fatal: not a git repository",
182 "is not recognized as an internal or external command",
184 "cwd does not exist",
185 "outside the sandbox root",
189_MISSING_TOOL_MARKERS: tuple[str, ...] = (
192 "is not a known tool",
200_SHELL_WORKAROUND_COMMANDS: dict[str, str] = {
206 "grep":
"search_files",
207 "rg":
"search_files",
208 "findstr":
"search_files",
211 "find":
"search_files",
217 """Raised for user-facing errors that map to exit code 2."""
220@dataclass(frozen=
True)
222 """One mined observation: its category, where it came from, what it said."""
230 """Return the JSON projection of this finding."""
241 """One discovered session and the substrates found for it."""
247 meta_path: Optional[Path] =
None
248 chat_log_path: Optional[Path] =
None
249 event_dirs: tuple[Path, ...] = ()
252 """Return every file this record will be read from, in a stable order."""
253 paths: list[Path] = []
260 paths.extend(sorted(p
for p
in event_dir.iterdir()
if p.is_file()))
268 """What the miner searched, what it examined, and what it matched."""
273 examined: list[str] = field(default_factory=list)
274 records: list[SessionRecord] = field(default_factory=list)
280 """Collapse *value* to one bounded line, verbatim apart from whitespace."""
281 text = value
if isinstance(value, str)
else json.dumps(value, default=str, sort_keys=
True)
282 flat =
" ".join(text.split())
283 if len(flat) > EXCERPT_MAX_CHARS:
284 return flat[: EXCERPT_MAX_CHARS - 1] +
"…"
289 """Read *path* as JSON, tolerating a BOM. Returns ``None`` when unreadable."""
292 raw = path.read_bytes()
293 except OSError
as exc:
294 _dbg(
"OC_CHAT.REPLAY", func, f
"EXTERNAL_ERROR: unreadable input {path}: {exc}", 2)
296 if raw.startswith(b
"\xef\xbb\xbf"):
299 return json.loads(raw.decode(
"utf-8", errors=
"replace"))
300 except json.JSONDecodeError
as exc:
301 _dbg(
"OC_CHAT.REPLAY", func, f
"VALIDATION_ERROR: malformed JSON in {path}: {exc}", 2)
306 """Return the hex SHA-256 of *path*, or ``"unreadable"`` when it cannot be read."""
308 digest = hashlib.sha256()
310 with open(path,
"rb")
as handle:
311 for chunk
in iter(
lambda: handle.read(65536), b
""):
313 except OSError
as exc:
314 _dbg(
"OC_CHAT.REPLAY", func, f
"EXTERNAL_ERROR: cannot hash {path}: {exc}", 2)
316 return digest.hexdigest()
320 """Return the current ``*_{kind}.json`` in *session_dir*, or ``None``.
322 Mirrors ``persistence._glob_latest`` (most-recently-modified wins) WITHOUT
323 its raise-on-miss and, crucially, without the ``_ensure_dir`` side effect
324 the surrounding persistence path helpers carry.
327 matches = sorted(session_dir.glob(f
"*_{kind}.json"), key=
lambda p: p.stat().st_mtime)
330 return matches[-1]
if matches
else None
334 """Return a stable string form of tool arguments for identity comparison."""
335 if isinstance(value, str):
337 value = json.loads(value)
338 except json.JSONDecodeError:
339 return " ".join(value.split())
341 return json.dumps(value, sort_keys=
True, default=str)
342 except (TypeError, ValueError):
347 """Parse an RFC3339 timestamp; return ``None`` for anything unparseable."""
348 if not isinstance(value, str)
or not value:
350 text = value[:-1] +
"+00:00" if value.endswith(
"Z")
else value
352 return datetime.fromisoformat(text)
358 """Return the first marker in *markers* present in *text*, else ``None``."""
359 for marker
in markers:
368 """Return the spellings of *plan_id* a session name or metadata may carry."""
369 return (plan_id, f
"plan-{plan_id}", f
"plan_{plan_id}", f
"plan {plan_id}")
373 """True when *name* references *plan_id* as a token, not as a substring.
375 ``2240`` and ``1224`` must not answer for plan ``224``, so the id has to sit
376 on a token boundary — the separators a session name actually uses are
377 hyphen, underscore, space and dot.
379 pattern = re.compile(rf
"(?:^|[^0-9a-z]){re.escape(plan_id)}(?:$|[^0-9a-z])", re.IGNORECASE)
380 return bool(pattern.search(name))
383def _match_reason(plan_id: str, dir_name: str, meta: Optional[dict]) -> Optional[str]:
384 """Return why this session matches *plan_id*, or ``None`` when it does not."""
387 if isinstance(meta, dict):
388 session_name = str(meta.get(
"session_name")
or "")
389 active_plan = str(meta.get(
"active_plan_id")
or "")
391 return f
"metadata active_plan_id={active_plan!r}"
393 return f
"directory name {dir_name!r} references the plan id"
395 return f
"metadata session_name={session_name!r}"
397 if token.lower()
in dir_name.lower():
398 return f
"directory name {dir_name!r} contains {token!r}"
402def _event_dirs_for(name: str, meta: Optional[dict], event_root: Path) -> tuple[Path, ...]:
403 """Return the existing event directories for a session, in merge order.
405 Mirrors ``event_bootstrap.event_read_dirs`` from the persisted metadata
406 rather than from a live ``ChatSession``, because constructing one to ask a
407 question about a stored session would be a write-capable object in a
410 func =
"_event_dirs_for"
412 seen: set[str] = set()
414 def _add(path: Optional[Path]) ->
None:
418 if not path.is_dir():
420 key = str(path.resolve(strict=
False)).casefold()
429 aliases: list[str] = []
430 if isinstance(meta, dict):
431 session_id = str(meta.get(
"session_id")
or "")
432 raw_aliases = meta.get(
"legacy_event_slugs")
433 if isinstance(raw_aliases, (list, tuple)):
434 aliases = [a
for a
in raw_aliases
if isinstance(a, str)]
436 name_slug = _slug(name)
437 id_key = session_id
or name_slug
438 if name_slug != id_key:
439 _add(event_root / name_slug)
440 for alias
in aliases:
441 _add(_contained_alias_dir(event_root, alias))
442 _add(event_root / id_key)
443 _dbg(
"OC_CHAT.REPLAY", func, f
"{name}: {len(out)} event dir(s)", 3)
452 meta_path: Optional[Path] =
None,
454 """Build a :class:`SessionRecord` for an existing session directory.
456 *meta* / *meta_path* let a caller that has already read the metadata (the
457 store search does, to decide the match) hand it over rather than have the
458 file opened a second time.
460 if meta_path
is None:
462 meta =
_read_json(meta_path)
if meta_path
is not None else None
463 meta_dict = meta
if isinstance(meta, dict)
else None
464 name = session_dir.name
465 if meta_dict
and isinstance(meta_dict.get(
"session_name"), str)
and meta_dict[
"session_name"]:
466 name = meta_dict[
"session_name"]
469 directory=session_dir,
471 session_id=str((meta_dict
or {}).get(
"session_id")
or ""),
479 """Resolve one ``--session`` argument to a session directory.
481 Accepts the directory itself or either of its two sidecar files, because a
482 caller who has just been reading a ``*_meta.json`` should be able to paste
488 if not path.is_dir():
489 raise _UserError(f
"--session path is not a session directory: {raw}")
497 explicit: Sequence[str] = (),
499 """Resolve the sessions for *plan_id*, recording what was searched.
501 An explicit list wins outright: the operator naming sessions is a stronger
502 signal than any name heuristic, and honouring it silently alongside a store
503 search would make the report's provenance ambiguous.
505 func =
"discover_sessions"
511 _dbg(
"OC_CHAT.REPLAY", func, f
"explicit mode: {len(records)} session(s)", 2)
514 sessions_dir=str(sessions_dir),
515 event_root=str(event_root),
516 examined=[str(r.directory)
for r
in records],
520 examined: list[str] = []
521 records: list[SessionRecord] = []
522 if sessions_dir.is_dir():
523 for child
in sorted(sessions_dir.iterdir()):
524 if not child.is_dir()
or child.name
in (ARCHIVE_DIR_NAME, LEGACY_DIR_NAME):
526 examined.append(child.name)
528 meta =
_read_json(meta_path)
if meta_path
is not None else None
529 reason =
_match_reason(plan_id, child.name, meta
if isinstance(meta, dict)
else None)
530 if reason
is not None:
531 records.append(
_record_for_dir(child, reason, event_root, meta, meta_path))
533 "OC_CHAT.REPLAY", func,
534 f
"store search: examined {len(examined)}, matched {len(records)}", 2,
538 sessions_dir=str(sessions_dir),
539 event_root=str(event_root),
548 """Return the parsed dict a ``role:"tool"`` message carries, if it is one."""
549 content = message.get(
"content")
550 if not isinstance(content, str)
or not content.strip():
553 parsed = json.loads(content)
554 except json.JSONDecodeError:
556 return parsed
if isinstance(parsed, dict)
else None
560 session: str, locus: str, tool_name: str, payload: dict, raw: str,
561) -> Optional[Finding]:
562 """Return the ONE outcome-family finding for a tool result, if any.
564 Precedence, most specific first: a missing dependency is a missing-tool
565 workaround; an error or a non-zero return code is drift when its text names
566 the anchor class of failure, otherwise a tool-call failure; a pending
567 confirmation is a denial in the same sense the design means it (the call
570 if payload.get(
"missing_dependency"):
572 "missing_tool_workarounds", session, locus,
573 _excerpt(f
"{tool_name}: missing_dependency={payload['missing_dependency']!r} "
574 f
"hint={payload.get('install_hint', '')!r}"),
576 error = payload.get(
"error")
577 if isinstance(error, str)
and error.strip():
578 category =
"cwd_anchor_drift" if _first_marker(error, _DRIFT_MARKERS)
else "tool_call_failures_and_denials"
579 return Finding(category, session, locus,
_excerpt(f
"{tool_name}: {error}"))
580 if payload.get(
"requires_confirmation"):
582 "tool_call_failures_and_denials", session, locus,
583 _excerpt(f
"{tool_name}: awaiting confirmation for "
584 f
"{payload.get('command', payload.get('message', ''))}"),
586 returncode = payload.get(
"returncode")
587 if isinstance(returncode, int)
and returncode != 0:
588 stderr = payload.get(
"stderr")
if isinstance(payload.get(
"stderr"), str)
else ""
589 detail = stderr
or raw
590 category =
"cwd_anchor_drift" if _first_marker(detail, _DRIFT_MARKERS)
else "errors"
591 return Finding(category, session, locus,
_excerpt(f
"{tool_name}: rc={returncode} {stderr}"))
596 """Return the warning-family findings for a tool result.
598 A declared ``warnings`` entry is one finding each; stderr text alongside a
599 SUCCESSFUL command is the undeclared form of the same thing (a failing
600 command's stderr is already reported by the outcome family, so counting it
601 here too would double-report one event).
603 out: list[Finding] = []
604 warnings = payload.get(
"warnings")
605 if isinstance(warnings, (list, tuple)):
606 for warning
in warnings:
608 out.append(
Finding(
"warnings", session, locus,
_excerpt(f
"{tool_name}: {warning}")))
609 stderr = payload.get(
"stderr")
610 if payload.get(
"returncode") == 0
and isinstance(stderr, str)
and stderr.strip():
611 out.append(
Finding(
"warnings", session, locus,
_excerpt(f
"{tool_name}: stderr {stderr}")))
616 """Return the native tool a shelled-out command duplicates, else ``None``."""
617 if tool_name !=
"run_command":
620 if isinstance(args, str):
622 args = json.loads(args)
623 except json.JSONDecodeError:
625 if not isinstance(args, dict):
627 command = args.get(
"command")
628 if not isinstance(command, str)
or not command.strip():
630 base = command.split()[0].rsplit(
"/", 1)[-1].rsplit(
"\\", 1)[-1].lower()
631 base = base[:-4]
if base.endswith(
".exe")
else base
632 return _SHELL_WORKAROUND_COMMANDS.get(base)
636 """Extract findings from one session's chat-log message list."""
637 func =
"_mine_chat_log"
638 findings: list[Finding] = []
639 call_sequence: list[tuple[str, str, int]] = []
641 for index, message
in enumerate(messages):
642 if not isinstance(message, dict):
644 role = message.get(
"role")
645 locus = f
"chat_log[message {index}]"
646 raw_content = message.get(
"content")
if isinstance(message.get(
"content"), str)
else ""
649 tool_name = str(message.get(
"name")
or "?")
650 locus = f
"chat_log[message {index}] tool={tool_name}"
652 if payload
is not None:
653 outcome =
_outcome_finding(record.name, locus, tool_name, payload, raw_content)
654 if outcome
is not None:
655 findings.append(outcome)
657 if payload.get(
"truncated")
is True:
659 "truncations", record.name, locus,
660 _excerpt(f
"{tool_name}: truncated=true total_size={payload.get('total_size')}"),
664 if marker
is not None:
665 findings.append(
Finding(
"truncations", record.name, locus,
_excerpt(f
"{tool_name}: {marker}")))
667 if missing
is not None:
669 "missing_tool_workarounds", record.name, locus,
_excerpt(raw_content),
672 elif role ==
"assistant":
674 if marker
is not None:
675 findings.append(
Finding(
"truncations", record.name, locus,
_excerpt(marker)))
676 for call
in message.get(
"tool_calls")
or []:
677 if not isinstance(call, dict):
679 function = call.get(
"function")
if isinstance(call.get(
"function"), dict)
else {}
680 name = str(function.get(
"name")
or call.get(
"name")
or "?")
681 arguments = function.get(
"arguments", call.get(
"arguments"))
684 if native
is not None:
686 "missing_tool_workarounds", record.name,
687 f
"chat_log[message {index}] tool={name}",
688 _excerpt(f
"shelled out where {native} exists: {_canonical_args(arguments)}"),
691 findings.extend(
_retry_findings(record.name, call_sequence,
"chat_log"))
692 _dbg(
"OC_CHAT.REPLAY", func, f
"{record.name}: {len(findings)} chat-log finding(s)", 3)
696def _retry_findings(session: str, sequence: list[tuple[str, str, int]], substrate: str) -> list[Finding]:
697 """Return one finding per maximal run of >= 2 identical consecutive calls."""
698 findings: list[Finding] = []
700 while start < len(sequence):
702 while end < len(sequence)
and sequence[end][:2] == sequence[start][:2]:
705 if run >= _RETRY_RUN_MIN:
706 name, args, first_locus = sequence[start]
708 "retry_loops", session,
709 f
"{substrate}[{first_locus}..{sequence[end - 1][2]}]",
710 _excerpt(f
"{run}x identical consecutive call to {name}({args})"),
719 """Return a stable, readable locus string for one event."""
720 turn = (getattr(event,
"turn_id",
None)
or "")[:8]
721 return f
"events[{getattr(event, 'event_id', '?')}]" + (f
" turn={turn}" if turn
else "")
724def _mine_events(record: SessionRecord, events: list, gap_seconds: float) -> list[Finding]:
725 """Extract findings from one session's ordered event stream."""
726 func =
"_mine_events"
727 findings: list[Finding] = []
728 proposals: list[tuple[str, str, int]] = []
730 for index, event
in enumerate(events):
731 payload = getattr(event,
"payload",
None)
732 kind = getattr(event,
"kind",
"")
737 "errors", record.name, locus,
_excerpt(getattr(payload,
"error_summary",
"")
or "error"),
739 elif kind ==
"hook_executed" and getattr(payload,
"exit_code", 0):
741 "errors", record.name, locus,
742 _excerpt(f
"hook {getattr(payload, 'hook_name', '?')} exited "
743 f
"{getattr(payload, 'exit_code', '?')}"),
745 elif kind ==
"session_ended":
746 outcome = str(getattr(payload,
"outcome",
"")
or "")
747 if outcome
and outcome
not in _OK_OUTCOMES:
749 "errors", record.name, locus,
_excerpt(f
"session ended with outcome={outcome}"),
751 elif kind ==
"tool_call_completed":
752 outcome = str(getattr(payload,
"outcome",
"")
or "")
753 if outcome
and outcome
not in _OK_OUTCOMES:
754 summary = getattr(payload,
"error_summary",
None)
or ""
756 "tool_call_failures_and_denials", record.name, locus,
757 _excerpt(f
"outcome={outcome} {summary}".strip()),
759 elif kind ==
"permission_decision":
760 decision = str(getattr(payload,
"decision",
"")
or "").lower()
761 final = str(getattr(payload,
"final_outcome",
"")
or "").lower()
762 if decision
in _DENY_DECISIONS
or final
in _DENY_DECISIONS:
763 reason = getattr(payload,
"denial_reason_code",
None)
or ""
765 "tool_call_failures_and_denials", record.name, locus,
766 _excerpt(f
"decision={decision or final} by "
767 f
"{getattr(payload, 'decided_by', '?')} {reason}".strip()),
769 elif kind ==
"provider_retried":
771 "retry_loops", record.name, locus,
772 _excerpt(f
"provider {getattr(payload, 'provider_name', '?')} retry attempt "
773 f
"{getattr(payload, 'attempt', '?')}"),
775 elif kind ==
"provider_rate_limited":
777 "retry_loops", record.name, locus,
778 _excerpt(f
"provider {getattr(payload, 'provider_name', '?')} rate limited; "
779 f
"retry_after={getattr(payload, 'retry_after_seconds', None)}"),
781 elif kind ==
"tool_call_proposed":
783 str(getattr(payload,
"tool_name",
"")
or "?"),
787 elif kind ==
"assistant_message":
788 marker =
_first_marker(str(getattr(payload,
"text",
"")
or ""), _TRUNCATION_MARKERS)
789 if marker
is not None:
790 findings.append(
Finding(
"truncations", record.name, locus,
_excerpt(marker)))
793 findings.extend(
_gap_findings(record.name, events, gap_seconds))
794 _dbg(
"OC_CHAT.REPLAY", func, f
"{record.name}: {len(findings)} event finding(s)", 3)
798def _gap_findings(session: str, events: list, gap_seconds: float) -> list[Finding]:
799 """Return one finding per pair of consecutive events separated by blocked time."""
800 findings: list[Finding] = []
801 previous: Optional[tuple[datetime, Any]] =
None
803 stamp =
_parse_ts(getattr(event,
"ts",
None))
806 if previous
is not None:
807 delta = (stamp - previous[0]).total_seconds()
808 if delta >= gap_seconds:
810 "blocked_time_gaps", session,
811 f
"{_event_locus(previous[1])} -> {_event_locus(event)}",
812 _excerpt(f
"{delta:.0f}s of silence between "
813 f
"{getattr(previous[1], 'kind', '?')} and {getattr(event, 'kind', '?')}"),
815 previous = (stamp, event)
819def mine_session(record: SessionRecord, gap_seconds: float = DEFAULT_GAP_SECONDS) -> list[Finding]:
820 """Mine one session across both substrates, in category-stable order."""
821 func =
"mine_session"
822 findings: list[Finding] = []
824 if record.chat_log_path
is not None:
826 messages = chat_log.get(
"messages")
if isinstance(chat_log, dict)
else None
827 if isinstance(messages, list):
831 for event_dir
in record.event_dirs:
832 read, _skipped = read_events_from_dir(event_dir)
834 if record.session_id:
835 events = [e
for e
in events
if getattr(e,
"session_id",
"") == record.session_id]
836 events.sort(key=
lambda e: (str(getattr(e,
"ts",
"")),))
838 findings.extend(
_mine_events(record, events, gap_seconds))
840 _dbg(
"OC_CHAT.REPLAY", func, f
"{record.name}: {len(findings)} finding(s) total", 2)
846def build_report(plan_id: str, discovery: Discovery, gap_seconds: float = DEFAULT_GAP_SECONDS) -> dict:
847 """Mine every discovered session and assemble the report structure."""
848 func =
"build_report"
849 findings: list[Finding] = []
850 sessions: list[dict] = []
851 inputs: list[dict] = []
853 for record
in discovery.records:
855 findings.extend(record_findings)
857 "session": record.name,
858 "directory": str(record.directory),
859 "match_reason": record.match_reason,
860 "session_id": record.session_id,
862 "metadata": str(record.meta_path)
if record.meta_path
else None,
863 "chat_log": str(record.chat_log_path)
if record.chat_log_path
else None,
864 "event_dirs": [str(d)
for d
in record.event_dirs],
866 "finding_count": len(record_findings),
868 for path
in record.input_paths():
872 "bytes": path.stat().st_size
if path.exists()
else 0,
875 by_category = {category: 0
for category
in CATEGORIES}
876 for finding
in findings:
877 by_category[finding.category] = by_category.get(finding.category, 0) + 1
879 ordered = sorted(findings, key=
lambda f: (CATEGORIES.index(f.category), f.session, f.locus))
880 _dbg(
"OC_CHAT.REPLAY", func, f
"plan {plan_id}: {len(ordered)} finding(s)", 2)
882 "schema": SCHEMA_VERSION,
885 "mode": discovery.mode,
887 "sessions_dir": discovery.sessions_dir,
888 "event_root": discovery.event_root,
889 "session_dirs_examined": discovery.examined,
894 "totals": {
"findings": len(ordered),
"by_category": by_category},
895 "findings": [f.to_json()
for f
in ordered],
896 "gap_seconds": gap_seconds,
901 """Render *report* as the JSON sidecar (stable key order, trailing newline)."""
902 return json.dumps(report, indent=2, sort_keys=
False, ensure_ascii=
False) +
"\n"
906 """Render *report* as the markdown view of the SAME findings.
908 Counts come from ``totals.by_category`` rather than from a second pass over
909 the findings, so the two outputs cannot disagree by construction.
911 plan_id = report[
"plan_id"]
912 discovery = report[
"discovery"]
913 searched = discovery[
"searched"]
914 totals = report[
"totals"]
916 f
"# Plan {plan_id} — session postmortem",
918 f
"Mined by `python -m oc_chat.scripts.session_postmortem {plan_id}` "
919 f
"({report['schema']}). Read-only over every input; findings are candidates for "
920 "operator judgement at the gate, never filed automatically.",
922 "## What was searched",
924 f
"- Discovery mode: `{discovery['mode']}`",
925 f
"- Session store: `{searched['sessions_dir']}`",
926 f
"- Event root: `{searched['event_root']}`",
927 f
"- Session directories examined: {len(searched['session_dirs_examined'])}"
928 + (f
" ({', '.join(searched['session_dirs_examined'])})" if searched[
"session_dirs_examined"]
else ""),
929 f
"- Blocked-time threshold: {report['gap_seconds']:.0f}s",
934 if not discovery[
"matched"]:
936 f
"No session matched plan {plan_id}. Nothing was mined; this postmortem records the "
937 "search, not a finding of health.",
941 lines.append(
"| Session | Match reason | Chat log | Event dirs | Findings |")
942 lines.append(
"|---|---|---|---|---|")
943 for entry
in discovery[
"matched"]:
944 substrates = entry[
"substrates"]
946 f
"| `{entry['session']}` | {entry['match_reason']} | "
947 f
"{'yes' if substrates['chat_log'] else 'no'} | "
948 f
"{len(substrates['event_dirs'])} | {entry['finding_count']} |"
955 f
"Total: {totals['findings']} across {len(CATEGORIES)} categories. "
956 "A category with zero findings is listed as zero — an empty section is information, "
960 by_category = totals[
"by_category"]
961 findings = report[
"findings"]
962 for category
in CATEGORIES:
963 count = by_category.get(category, 0)
964 lines.append(f
"### {CATEGORY_TITLES[category]} — {count} finding(s)")
967 lines.append(
"_None._")
970 for finding
in findings:
971 if finding[
"category"] != category:
973 lines.append(f
"- `{finding['session']}` @ `{finding['locus']}` — {finding['excerpt']}")
977 "## Inputs (read-only)",
979 f
"{len(report['inputs'])} file(s) were opened for reading. Their digests are recorded so a "
980 "reader can prove the miner left the store byte-identical.",
983 for entry
in report[
"inputs"]:
984 lines.append(f
"- `{entry['path']}` — sha256 `{entry['sha256']}` ({entry['bytes']} bytes)")
986 return "\n".join(lines)
990 """Write *text* to *path* via a temp file + ``os.replace``.
992 A crash mid-emit must not leave a torn report. Inputs are untouched either
993 way — the miner only ever writes here.
995 func =
"_write_atomic"
996 path.parent.mkdir(parents=
True, exist_ok=
True)
997 tmp = path.with_name(path.name +
".tmp")
998 tmp.write_text(text, encoding=
"utf-8", newline=
"\n")
999 os.replace(tmp, path)
1000 _dbg(
"OC_CHAT.REPLAY", func, f
"wrote {path}", 2)
1003def emit(report: dict, out_dir: Path) -> tuple[Path, Path]:
1004 """Write the markdown + JSON pair for *report*; return both paths."""
1005 plan_id = report[
"plan_id"]
1006 md_path = out_dir / f
"{plan_id}-postmortem.md"
1007 json_path = out_dir / f
"{plan_id}-postmortem.json"
1010 return md_path, json_path
1016 """Return the committed postmortem home, resolved from this file's location."""
1017 return Path(__file__).resolve().parents[1] /
"docs" /
"reports" /
"postmortems"
1021 """Build the ``session_postmortem`` argument parser."""
1022 parser = argparse.ArgumentParser(
1023 prog=
"python -m oc_chat.scripts.session_postmortem",
1024 description=
"Mine a plan's oc_chat dogfood sessions into a postmortem (read-only).",
1026 parser.add_argument(
"plan_id", help=
"the plan number, e.g. 224 (lettered ids such as 148g are accepted)")
1027 parser.add_argument(
"--session", action=
"append", default=[], metavar=
"PATH",
1028 help=
"explicit session directory (repeatable); takes precedence over the store search")
1029 parser.add_argument(
"--sessions-dir", default=
None, help=
"session store root to search")
1031 parser.add_argument(
"--event-root", default=
None, help=
"event-log root (default: OC_CHAT_EVENT_DIR or <cwd>/.oc_chat/events)")
1032 parser.add_argument(
"--out-dir", default=
None, help=
"where to write the two postmortem files")
1033 parser.add_argument(
"--gap-seconds", type=float, default=DEFAULT_GAP_SECONDS,
1034 help=f
"blocked-time threshold in seconds (default {DEFAULT_GAP_SECONDS:.0f})")
1035 parser.add_argument(
"--quiet", action=
"store_true", help=
"suppress the stdout summary")
1040 """Resolve the event root: the flag, else the env override, else the default."""
1043 env_dir = os.environ.get(
"OC_CHAT_EVENT_DIR")
1044 return Path(env_dir)
if env_dir
else Path.cwd() /
".oc_chat" /
"events"
1047def main(argv: Optional[Sequence[str]] =
None) -> int:
1048 """Entry point. Returns the process exit code (0 ok, 1 error, 2 user error)."""
1050 for stream
in (sys.stdout, sys.stderr):
1052 stream.reconfigure(errors=
"replace")
1053 except (AttributeError, ValueError):
1057 plan_id = str(args.plan_id).strip()
1058 if not PLAN_ID_RE.match(plan_id):
1059 raise _UserError(f
"plan id must match {PLAN_ID_RE.pattern}; got {args.plan_id!r}")
1060 if args.gap_seconds <= 0:
1061 raise _UserError(
"--gap-seconds must be positive")
1062 sessions_dir = Path(args.sessions_dir)
if args.sessions_dir
else Path(DEFAULT_SESSIONS_DIR)
1065 _dbg(
"OC_CHAT.REPLAY", func, f
"plan={plan_id} store={sessions_dir} events={event_root}", 2)
1068 report =
build_report(plan_id, discovery, args.gap_seconds)
1069 md_path, json_path =
emit(report, out_dir)
1071 print(f
"plan {plan_id}: {len(discovery.records)} session(s) matched, "
1072 f
"{report['totals']['findings']} finding(s)")
1073 print(f
" {md_path}")
1074 print(f
" {json_path}")
1076 except _UserError
as exc:
1077 print(f
"error: {exc}", file=sys.stderr)
1079 except Exception
as exc:
1080 _dbg(
"OC_CHAT.REPLAY", func, f
"SYSTEM_ERROR: {type(exc).__name__}: {exc}", 1, override=
True)
1081 print(f
"error: {type(exc).__name__}: {exc}", file=sys.stderr)
1085if __name__ ==
"__main__":
dict[str, str] to_json(self)
list[Path] input_paths(self)
tuple[Path,...] _event_dirs_for(str name, Optional[dict] meta, Path event_root)
tuple[str,...] _plan_tokens(str plan_id)
list[Finding] _retry_findings(str session, list[tuple[str, str, int]] sequence, str substrate)
str _event_locus(Any event)
Optional[Finding] _outcome_finding(str session, str locus, str tool_name, dict payload, str raw)
SessionRecord _record_for_dir(Path session_dir, str reason, Path event_root, Any meta=None, Optional[Path] meta_path=None)
list[Finding] _warning_findings(str session, str locus, str tool_name, dict payload)
Optional[Path] _latest_kind(Path session_dir, str kind)
str render_json(dict report)
Path _resolve_explicit(str raw)
list[Finding] mine_session(SessionRecord record, float gap_seconds=DEFAULT_GAP_SECONDS)
list[Finding] _gap_findings(str session, list events, float gap_seconds)
list[Finding] _mine_events(SessionRecord record, list events, float gap_seconds)
bool _name_references_plan(str name, str plan_id)
Path _resolve_event_root(Optional[str] raw)
Optional[Any] _read_json(Path path)
dict build_report(str plan_id, Discovery discovery, float gap_seconds=DEFAULT_GAP_SECONDS)
Discovery discover_sessions(str plan_id, Path sessions_dir, Path event_root, Sequence[str] explicit=())
str render_markdown(dict report)
Optional[dict] _tool_payload(dict message)
Optional[datetime] _parse_ts(Any value)
list[Finding] _mine_chat_log(SessionRecord record, list messages)
Optional[str] _first_marker(str text, Sequence[str] markers)
Optional[str] _match_reason(str plan_id, str dir_name, Optional[dict] meta)
argparse.ArgumentParser _build_parser()
Optional[str] _shell_workaround(str tool_name, Any arguments)
str _canonical_args(Any value)
tuple[Path, Path] emit(dict report, Path out_dir)
None _write_atomic(Path path, str text)