oc_chat
Loading...
Searching...
No Matches
oc_chat.scripts.session_postmortem Namespace Reference

Classes

class  _UserError
class  Finding
class  SessionRecord
class  Discovery

Functions

str _excerpt (Any value)
Optional[Any] _read_json (Path path)
str _sha256 (Path path)
Optional[Path] _latest_kind (Path session_dir, str kind)
str _canonical_args (Any value)
Optional[datetime] _parse_ts (Any value)
Optional[str] _first_marker (str text, Sequence[str] markers)
tuple[str,...] _plan_tokens (str plan_id)
bool _name_references_plan (str name, str plan_id)
Optional[str] _match_reason (str plan_id, str dir_name, Optional[dict] meta)
tuple[Path,...] _event_dirs_for (str name, Optional[dict] meta, Path event_root)
SessionRecord _record_for_dir (Path session_dir, str reason, Path event_root, Any meta=None, Optional[Path] meta_path=None)
Path _resolve_explicit (str raw)
Discovery discover_sessions (str plan_id, Path sessions_dir, Path event_root, Sequence[str] explicit=())
Optional[dict] _tool_payload (dict message)
Optional[Finding] _outcome_finding (str session, str locus, str tool_name, dict payload, str raw)
list[Finding] _warning_findings (str session, str locus, str tool_name, dict payload)
Optional[str] _shell_workaround (str tool_name, Any arguments)
list[Finding] _mine_chat_log (SessionRecord record, list messages)
list[Finding] _retry_findings (str session, list[tuple[str, str, int]] sequence, str substrate)
str _event_locus (Any event)
list[Finding] _mine_events (SessionRecord record, list events, float gap_seconds)
list[Finding] _gap_findings (str session, list events, float gap_seconds)
list[Finding] mine_session (SessionRecord record, float gap_seconds=DEFAULT_GAP_SECONDS)
dict build_report (str plan_id, Discovery discovery, float gap_seconds=DEFAULT_GAP_SECONDS)
str render_json (dict report)
str render_markdown (dict report)
None _write_atomic (Path path, str text)
tuple[Path, Path] emit (dict report, Path out_dir)
Path default_out_dir ()
argparse.ArgumentParser _build_parser ()
Path _resolve_event_root (Optional[str] raw)
int main (Optional[Sequence[str]] argv=None)

Variables

str SCHEMA_VERSION = "postmortem/v1"
tuple CATEGORIES
dict CATEGORY_TITLES
 PLAN_ID_RE = re.compile(r"^[0-9]{1,4}[a-z]?$")
int EXCERPT_MAX_CHARS = 200
float DEFAULT_GAP_SECONDS = 600.0
int _RETRY_RUN_MIN = 2
 _OK_OUTCOMES = frozenset({"ok", "success"})
 _DENY_DECISIONS = frozenset({"deny", "denied", "reject", "rejected"})
tuple _TRUNCATION_MARKERS
tuple _DRIFT_MARKERS
tuple _MISSING_TOOL_MARKERS
dict _SHELL_WORKAROUND_COMMANDS

Detailed Description

The session miner — what actually happened in a plan's dogfood sessions.

Purpose
-------
Worker sessions produce friction evidence — errors, denials, retry loops,
truncations, workarounds — that evaporates when the session ends. This module
mines it mechanically, per the ratified design
``docs/architecture/AGENT_DISPATCH_IDENTITY_DESIGN_2026-08-26.md`` §4.7: it
reads the plan's oc_chat dogfood sessions (session-store content — turns, tool
calls and their outcomes — plus the event streams) and emits a per-plan
findings pair at ``docs/reports/postmortems/<NNN>-postmortem.md`` and
``<NNN>-postmortem.json``.

The miner never concludes and never files anything. Findings become backlog
items only through operator decision at the gate (§4.7, design decision D4);
nothing here edits roadmap source, a plan, or any document beyond its own two
output files.

Responsibilities
----------------
- :func:`discover_sessions` — resolve which sessions belong to the plan. An
  explicit ``--session`` list takes precedence; otherwise the store is searched
  for sessions whose directory name, ``session_name`` or ``active_plan_id``
  references the plan id. What was searched and what matched is recorded in the
  output, so a miss is legible rather than silent.
- :func:`mine_session` — extract findings in the eight pinned categories
  (:data:`CATEGORIES`) from the chat log and the event stream.
- :func:`build_report` / :func:`render_json` / :func:`render_markdown` — the
  two emitters. They carry identical findings and their totals agree by
  construction: the markdown counts are read off the same ``by_category`` map
  the JSON publishes.
- :func:`main` — the ``python -m oc_chat.scripts.session_postmortem <NNN>`` CLI.

Read-only contract (the plan-212 miner's rule)
----------------------------------------------
Every input is opened for reading only. The module deliberately does NOT use
:mod:`oc_chat.core.persistence`'s directory helpers (``_sessions_root``,
``_session_dir``, ``_archive_dir``) because those call ``_ensure_dir`` and so
bring a directory into being as a side effect of asking about it; the local
:func:`_latest_kind` mirrors ``_glob_latest`` without that. The ONLY writes are
the two postmortem files, each written via a temp file plus ``os.replace`` so a
crash mid-emit cannot leave a torn report — and cannot touch an input either
way.

Diagnostics
-----------
Domain: OC_CHAT.REPLAY
    Shared with the offline replay reader deliberately: this is the same
    read-only-analysis-over-the-event-substrate surface, and the domain table in
    ``.option_c/debug_config.json`` gates on exact literals — an unlisted domain
    is silently threshold 0, and that file is outside this plan's scope.
Levels:
    L2 — CLI invocation, discovery outcome, emit.
    L3 — per-session substrate detail.
    L4 — module-load coverage marker.

Contracts
---------
- Exit 0 on success including the empty case (no sessions found still emits an
  honest postmortem naming what was searched); 2 on user error; 1 unexpected.
- Every category in :data:`CATEGORIES` appears in both outputs, zero included —
  an empty section is information, not omission.
- Output carries no wall-clock stamp, so re-running over unchanged inputs
  produces byte-identical files (a mined report is a function of its inputs).
- Findings are lenses, not a partition: one message can raise findings in more
  than one category, but each (locus, signal) raises at most one finding, under
  the precedence documented on each detector.

Dependencies
------------
- oc_diagnostics (structured debug logging via _dbg)
- oc_chat.core.event_bootstrap (_slug / _contained_alias_dir — reused rather
  than copied because alias validation and root containment are security-
  relevant path handling that must not drift)
- oc_chat.core.replay (read_events_from_dir — the tolerant NDJSON slot reader)
- oc_chat.core.persistence (DEFAULT_SESSIONS_DIR and the reserved dir names)

Function Documentation

◆ _build_parser()

argparse.ArgumentParser oc_chat.scripts.session_postmortem._build_parser ( )
protected
Build the ``session_postmortem`` argument parser.

Definition at line 1020 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _canonical_args()

str oc_chat.scripts.session_postmortem._canonical_args ( Any value)
protected
Return a stable string form of tool arguments for identity comparison.

Definition at line 333 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _event_dirs_for()

tuple[Path, ...] oc_chat.scripts.session_postmortem._event_dirs_for ( str name,
Optional[dict] meta,
Path event_root )
protected
Return the existing event directories for a session, in merge order.

Mirrors ``event_bootstrap.event_read_dirs`` from the persisted metadata
rather than from a live ``ChatSession``, because constructing one to ask a
question about a stored session would be a write-capable object in a
read-only tool.

Definition at line 402 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _event_locus()

str oc_chat.scripts.session_postmortem._event_locus ( Any event)
protected
Return a stable, readable locus string for one event.

Definition at line 718 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _excerpt()

str oc_chat.scripts.session_postmortem._excerpt ( Any value)
protected
Collapse *value* to one bounded line, verbatim apart from whitespace.

Definition at line 279 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _first_marker()

Optional[str] oc_chat.scripts.session_postmortem._first_marker ( str text,
Sequence[str] markers )
protected
Return the first marker in *markers* present in *text*, else ``None``.

Definition at line 357 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _gap_findings()

list[Finding] oc_chat.scripts.session_postmortem._gap_findings ( str session,
list events,
float gap_seconds )
protected
Return one finding per pair of consecutive events separated by blocked time.

Definition at line 798 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _latest_kind()

Optional[Path] oc_chat.scripts.session_postmortem._latest_kind ( Path session_dir,
str kind )
protected
Return the current ``*_{kind}.json`` in *session_dir*, or ``None``.

Mirrors ``persistence._glob_latest`` (most-recently-modified wins) WITHOUT
its raise-on-miss and, crucially, without the ``_ensure_dir`` side effect
the surrounding persistence path helpers carry.

Definition at line 319 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _match_reason()

Optional[str] oc_chat.scripts.session_postmortem._match_reason ( str plan_id,
str dir_name,
Optional[dict] meta )
protected
Return why this session matches *plan_id*, or ``None`` when it does not.

Definition at line 383 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _mine_chat_log()

list[Finding] oc_chat.scripts.session_postmortem._mine_chat_log ( SessionRecord record,
list messages )
protected
Extract findings from one session's chat-log message list.

Definition at line 635 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _mine_events()

list[Finding] oc_chat.scripts.session_postmortem._mine_events ( SessionRecord record,
list events,
float gap_seconds )
protected
Extract findings from one session's ordered event stream.

Definition at line 724 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _name_references_plan()

bool oc_chat.scripts.session_postmortem._name_references_plan ( str name,
str plan_id )
protected
True when *name* references *plan_id* as a token, not as a substring.

``2240`` and ``1224`` must not answer for plan ``224``, so the id has to sit
on a token boundary — the separators a session name actually uses are
hyphen, underscore, space and dot.

Definition at line 372 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _outcome_finding()

Optional[Finding] oc_chat.scripts.session_postmortem._outcome_finding ( str session,
str locus,
str tool_name,
dict payload,
str raw )
protected
Return the ONE outcome-family finding for a tool result, if any.

Precedence, most specific first: a missing dependency is a missing-tool
workaround; an error or a non-zero return code is drift when its text names
the anchor class of failure, otherwise a tool-call failure; a pending
confirmation is a denial in the same sense the design means it (the call
did not run).

Definition at line 559 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _parse_ts()

Optional[datetime] oc_chat.scripts.session_postmortem._parse_ts ( Any value)
protected
Parse an RFC3339 timestamp; return ``None`` for anything unparseable.

Definition at line 346 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _plan_tokens()

tuple[str, ...] oc_chat.scripts.session_postmortem._plan_tokens ( str plan_id)
protected
Return the spellings of *plan_id* a session name or metadata may carry.

Definition at line 367 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _read_json()

Optional[Any] oc_chat.scripts.session_postmortem._read_json ( Path path)
protected
Read *path* as JSON, tolerating a BOM. Returns ``None`` when unreadable.

Definition at line 288 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _record_for_dir()

SessionRecord oc_chat.scripts.session_postmortem._record_for_dir ( Path session_dir,
str reason,
Path event_root,
Any meta = None,
Optional[Path] meta_path = None )
protected
Build a :class:`SessionRecord` for an existing session directory.

*meta* / *meta_path* let a caller that has already read the metadata (the
store search does, to decide the match) hand it over rather than have the
file opened a second time.

Definition at line 447 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _resolve_event_root()

Path oc_chat.scripts.session_postmortem._resolve_event_root ( Optional[str] raw)
protected
Resolve the event root: the flag, else the env override, else the default.

Definition at line 1039 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _resolve_explicit()

Path oc_chat.scripts.session_postmortem._resolve_explicit ( str raw)
protected
Resolve one ``--session`` argument to a session directory.

Accepts the directory itself or either of its two sidecar files, because a
caller who has just been reading a ``*_meta.json`` should be able to paste
that path.

Definition at line 478 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _retry_findings()

list[Finding] oc_chat.scripts.session_postmortem._retry_findings ( str session,
list[tuple[str, str, int]] sequence,
str substrate )
protected
Return one finding per maximal run of >= 2 identical consecutive calls.

Definition at line 696 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _sha256()

str oc_chat.scripts.session_postmortem._sha256 ( Path path)
protected
Return the hex SHA-256 of *path*, or ``"unreadable"`` when it cannot be read.

Definition at line 305 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _shell_workaround()

Optional[str] oc_chat.scripts.session_postmortem._shell_workaround ( str tool_name,
Any arguments )
protected
Return the native tool a shelled-out command duplicates, else ``None``.

Definition at line 615 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _tool_payload()

Optional[dict] oc_chat.scripts.session_postmortem._tool_payload ( dict message)
protected
Return the parsed dict a ``role:"tool"`` message carries, if it is one.

Definition at line 547 of file session_postmortem.py.

Here is the caller graph for this function:

◆ _warning_findings()

list[Finding] oc_chat.scripts.session_postmortem._warning_findings ( str session,
str locus,
str tool_name,
dict payload )
protected
Return the warning-family findings for a tool result.

A declared ``warnings`` entry is one finding each; stderr text alongside a
SUCCESSFUL command is the undeclared form of the same thing (a failing
command's stderr is already reported by the outcome family, so counting it
here too would double-report one event).

Definition at line 595 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _write_atomic()

None oc_chat.scripts.session_postmortem._write_atomic ( Path path,
str text )
protected
Write *text* to *path* via a temp file + ``os.replace``.

A crash mid-emit must not leave a torn report. Inputs are untouched either
way — the miner only ever writes here.

Definition at line 989 of file session_postmortem.py.

Here is the caller graph for this function:

◆ build_report()

dict oc_chat.scripts.session_postmortem.build_report ( str plan_id,
Discovery discovery,
float gap_seconds = DEFAULT_GAP_SECONDS )
Mine every discovered session and assemble the report structure.

Definition at line 846 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ default_out_dir()

Path oc_chat.scripts.session_postmortem.default_out_dir ( )
Return the committed postmortem home, resolved from this file's location.

Definition at line 1015 of file session_postmortem.py.

Here is the caller graph for this function:

◆ discover_sessions()

Discovery oc_chat.scripts.session_postmortem.discover_sessions ( str plan_id,
Path sessions_dir,
Path event_root,
Sequence[str] explicit = () )
Resolve the sessions for *plan_id*, recording what was searched.

An explicit list wins outright: the operator naming sessions is a stronger
signal than any name heuristic, and honouring it silently alongside a store
search would make the report's provenance ambiguous.

Definition at line 493 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ emit()

tuple[Path, Path] oc_chat.scripts.session_postmortem.emit ( dict report,
Path out_dir )
Write the markdown + JSON pair for *report*; return both paths.

Definition at line 1003 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ main()

int oc_chat.scripts.session_postmortem.main ( Optional[Sequence[str]] argv = None)
Entry point. Returns the process exit code (0 ok, 1 error, 2 user error).

Definition at line 1047 of file session_postmortem.py.

Here is the call graph for this function:

◆ mine_session()

list[Finding] oc_chat.scripts.session_postmortem.mine_session ( SessionRecord record,
float gap_seconds = DEFAULT_GAP_SECONDS )
Mine one session across both substrates, in category-stable order.

Definition at line 819 of file session_postmortem.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ render_json()

str oc_chat.scripts.session_postmortem.render_json ( dict report)
Render *report* as the JSON sidecar (stable key order, trailing newline).

Definition at line 900 of file session_postmortem.py.

Here is the caller graph for this function:

◆ render_markdown()

str oc_chat.scripts.session_postmortem.render_markdown ( dict report)
Render *report* as the markdown view of the SAME findings.

Counts come from ``totals.by_category`` rather than from a second pass over
the findings, so the two outputs cannot disagree by construction.

Definition at line 905 of file session_postmortem.py.

Here is the caller graph for this function:

Variable Documentation

◆ _DENY_DECISIONS

oc_chat.scripts.session_postmortem._DENY_DECISIONS = frozenset({"deny", "denied", "reject", "rejected"})
protected

Definition at line 158 of file session_postmortem.py.

◆ _DRIFT_MARKERS

tuple oc_chat.scripts.session_postmortem._DRIFT_MARKERS
protected
Initial value:
1= (
2 "No such file or directory",
3 "The system cannot find the file specified",
4 "The system cannot find the path specified",
5 "cannot find the path",
6 "fatal: not a git repository",
7 "is not recognized as an internal or external command",
8 "No such directory",
9 "cwd does not exist",
10 "outside the sandbox root",
11)

Definition at line 176 of file session_postmortem.py.

◆ _MISSING_TOOL_MARKERS

tuple oc_chat.scripts.session_postmortem._MISSING_TOOL_MARKERS
protected
Initial value:
1= (
2 "Unknown tool",
3 "unknown tool",
4 "is not a known tool",
5 "No such tool",
6 "tool not found",
7)

Definition at line 189 of file session_postmortem.py.

◆ _OK_OUTCOMES

oc_chat.scripts.session_postmortem._OK_OUTCOMES = frozenset({"ok", "success"})
protected

Definition at line 155 of file session_postmortem.py.

◆ _RETRY_RUN_MIN

int oc_chat.scripts.session_postmortem._RETRY_RUN_MIN = 2
protected

Definition at line 152 of file session_postmortem.py.

◆ _SHELL_WORKAROUND_COMMANDS

dict oc_chat.scripts.session_postmortem._SHELL_WORKAROUND_COMMANDS
protected
Initial value:
1= {
2 "cat": "read_file",
3 "type": "read_file",
4 "head": "read_file",
5 "tail": "read_file",
6 "more": "read_file",
7 "grep": "search_files",
8 "rg": "search_files",
9 "findstr": "search_files",
10 "ls": "list_dir",
11 "dir": "list_dir",
12 "find": "search_files",
13 "tree": "list_dir",
14}

Definition at line 200 of file session_postmortem.py.

◆ _TRUNCATION_MARKERS

tuple oc_chat.scripts.session_postmortem._TRUNCATION_MARKERS
protected
Initial value:
1= (
2 "[output truncated at",
3 "[assistant reply truncated for API context",
4 "[Answer truncated",
5 "[Tool call(s) truncated",
6 "[File truncated at limit]",
7 "[File was truncated at read limit]",
8 "[truncated for summariser]",
9)

Definition at line 163 of file session_postmortem.py.

◆ CATEGORIES

tuple oc_chat.scripts.session_postmortem.CATEGORIES
Initial value:
1= (
2 "errors",
3 "warnings",
4 "tool_call_failures_and_denials",
5 "truncations",
6 "retry_loops",
7 "missing_tool_workarounds",
8 "cwd_anchor_drift",
9 "blocked_time_gaps",
10)

Definition at line 114 of file session_postmortem.py.

◆ CATEGORY_TITLES

dict oc_chat.scripts.session_postmortem.CATEGORY_TITLES
Initial value:
1= {
2 "errors": "Errors",
3 "warnings": "Warnings",
4 "tool_call_failures_and_denials": "Tool-call failures and denials",
5 "truncations": "Truncations",
6 "retry_loops": "Retry loops",
7 "missing_tool_workarounds": "Missing-tool workarounds",
8 "cwd_anchor_drift": "cwd / anchor drift",
9 "blocked_time_gaps": "Blocked-time gaps",
10}

Definition at line 126 of file session_postmortem.py.

◆ DEFAULT_GAP_SECONDS

float oc_chat.scripts.session_postmortem.DEFAULT_GAP_SECONDS = 600.0

Definition at line 148 of file session_postmortem.py.

◆ EXCERPT_MAX_CHARS

int oc_chat.scripts.session_postmortem.EXCERPT_MAX_CHARS = 200

Definition at line 144 of file session_postmortem.py.

◆ PLAN_ID_RE

oc_chat.scripts.session_postmortem.PLAN_ID_RE = re.compile(r"^[0-9]{1,4}[a-z]?$")

Definition at line 140 of file session_postmortem.py.

◆ SCHEMA_VERSION

str oc_chat.scripts.session_postmortem.SCHEMA_VERSION = "postmortem/v1"

Definition at line 109 of file session_postmortem.py.