oc_chat
Loading...
Searching...
No Matches
session_log.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/session_log.py
4
5"""
6Session log writer — records completed ``/goal`` turns.
7
8Purpose
9-------
10:func:`append_session_log` appends a record of a completed goal to the
11workspace ``SESSION_LOG.md``: an H2 section listing scope, the session
12reference, and the files the agent modified, with file bullets grouped
13under a Keep-a-Changelog ``### Changed`` subsection.
14
15**Why this is not the changelog (2026-07-14).** This writer previously
16targeted the root ``CHANGELOG.md``. That file is now a **registry-GENERATED
17view** — it is produced by ``roadmap render`` from ``roadmap/source/**`` and
18is guarded by ``render --check``. Hand-writing into it corrupted a generated
19artifact on every ``/goal`` turn and drifted the check. The entry format is
20unchanged; only the target moved. **The changelog is the registry's; the
21session log is this module's.**
22
23This lives in its own module (not next to the per-turn ReAct loop) because
24it runs *after* turns, not *during* them — sub-agents that don't run goals
25can construct an :class:`Agent` without ever importing it.
26
27Responsibilities
28----------------
29- :data:`SESSION_LOG_PATH` — the ``oc_chat`` subproject's ``SESSION_LOG.md``
30 (resolved at import time by a BOUNDED adjacent-marker check, always absolute).
31- :func:`append_session_log` — prepend a new H2 + ``### Changed`` section
32 after the metadata block and refresh the ``Last Session:`` line.
33- :func:`_session_log_path` — internal path resolver.
34- :func:`_project_version` — best-effort live version for the fresh-create
35 metadata block; never raises.
36
37Diagnostics
38-----------
39Domain: OC_CHAT.MAIN
40Levels:
41 L2 — lifecycle events
42 L3 — semantic details
43 L4 — deep tracing
44
45Contracts
46---------
47- **NEVER writes to ``CHANGELOG.md``** — that file is registry-generated
48 (``roadmap render``) and ``render --check``-gated. A test pins this.
49- Path resolution is a BOUNDED check (no upward walk): if ``pyproject.toml`` sits
50 DIRECTLY BESIDE the package (``oc_chat/pyproject.toml`` — source/editable), write
51 there; else (wheel, no adjacent marker) write ``~/.oc_chat/SESSION_LOG.md``. Never
52 walks upward — an upward walk adopts an unrelated host project above ``site-packages``
53 (review R18 SOL18-02). Always an ABSOLUTE path, never a CWD-relative name and never
54 inside a foreign project.
55- A goal with no recorded file changes is a no-op (no section written).
56- Section shape: ``## YYYY-MM-DD:HH:MM:SS — <scope>`` header, ``**Scope:**``
57 and ``**Session:**`` metadata, blank line, ``### Changed`` H3, blank line,
58 ``- `path``` bullets. Session links use the directory style
59 ``oc_chat/sessions/<name>/`` (trailing slash), never ``.json``.
60- The metadata block (``**Version:** ...``, ``**Last Session:** ...``) is
61 preserved when the file exists; the new section is inserted between the
62 metadata and the rest of the body.
63- Never raises out of the fresh-create path.
64
65Dependencies
66------------
67- oc_diagnostics (structured debug logging via _dbg)
68 (no ``oc_chat.core._paths`` dependency — the bounded check uses only ``__file__``.)
69- oc_chat.core._version (lazy; the single live-version entry point)
70- oc_chat.core.commands (lazy; Context type hint for append_session_log)
71"""
72from __future__ import annotations
73
74import os
75from datetime import datetime
76from pathlib import Path
77from typing import TYPE_CHECKING
78
79from oc_diagnostics import _dbg
80
81if TYPE_CHECKING:
82 from oc_chat.core.commands import Context
83
84
85_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
86
87
88# Project description matching pyproject.toml — used when first creating
89# a fresh SESSION_LOG.md.
90_PROJECT_DESCRIPTION = "Interactive Option C chat client with ReAct agentic loop"
91
92
93def _project_version() -> str:
94 """Return the live project version, or ``"0.0.0"`` as a last resort.
95
96 Historically this read the static ``[project].version`` key from
97 ``oc_chat/pyproject.toml``. Both pyprojects are now scm-``dynamic``
98 (Plan 143g-code S1) — there is no static ``version`` key to read — so
99 this routes through :func:`oc_chat.core._version.resolve_version`, the
100 single live-version entry point (which itself falls back through dist
101 metadata → the generated version file → ``"unknown"`` and never
102 raises). The ``"0.0.0"`` guard remains only for the impossible case of
103 the import itself failing; the fresh-create path must never raise.
104 """
105 func = "_project_version"
106 try:
107 from oc_chat.core._version import resolve_version
108 return resolve_version()
109 except Exception as exc: # pragma: no cover — resolve_version never raises
110 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {type(exc).__name__}: {exc}", 3)
111 return "0.0.0"
112
113
114def _session_log_path() -> Path:
115 """Resolve SESSION_LOG.md — always ABSOLUTE, always in a directory THIS app owns.
116
117 **BOUNDED, deliberately not an upward walk (2026-07-15, three review rounds).**
118 The rule is one comparison, no walking:
119
120 - ``pkg_dir = Path(__file__).parent.parent`` is the ``oc_chat`` package dir.
121 - **Source / editable checkout** — the subproject's own ``pyproject.toml`` sits
122 *directly beside* the package (``oc_chat/pyproject.toml``). Write
123 ``pkg_dir / "SESSION_LOG.md"``. Correct today AND post-migration (the moved
124 subtree keeps its own ``pyproject.toml``).
125 - **Wheel install** — no ``pyproject.toml`` beside the package. Write to a
126 user-owned state dir (``~/.oc_chat/SESSION_LOG.md``), NEVER inside the
127 installed package tree.
128
129 **Why NOT ``find_package_root`` / any upward walk (R16→R17→R18).** Each prior
130 fix walked upward for the nearest ``pyproject.toml`` and was wrong a new way:
131 R16 — ``find_workspace_root`` escaped to the monorepo root post-move; R17 —
132 the relative-path fallback rewrote an ambient CWD file; R18 (SOL18-02) — in the
133 common ``<host>/.venv/Lib/site-packages/oc_chat/…`` layout an upward walk exits
134 site-packages and adopts the **host project's** ``pyproject.toml``, so ``/goal``
135 would rewrite the *host's* ``SESSION_LOG.md`` (verified). **An upward walk can
136 never tell "the oc_chat subproject" from "an unrelated project above
137 site-packages" — so it is not used.** The bounded check above cannot escape the
138 package: it looks only *beside* the package, never above it.
139
140 *(Ideal, tracked as follow-up: resolve from an explicit workspace root supplied
141 by the active ``Context`` when one exists, rather than a module-import-time
142 global — SOL18-02 rec 1. The bounded rule here closes the corruption path; the
143 Context-based resolver is a larger change filed separately.)*
144 """
145 func = "_session_log_path"
146 pkg_dir = Path(__file__).resolve().parent.parent # .../oc_chat
147 if (pkg_dir / "pyproject.toml").is_file():
148 # Source/editable: the subproject's own pyproject is right here. No walk.
149 return pkg_dir / "SESSION_LOG.md"
150 # Wheel install: user-owned state dir, never inside the package, never a foreign project.
151 _dbg("OC_CHAT.MAIN", func, "no pyproject beside package (wheel); using ~/.oc_chat", 3)
152 return Path.home() / ".oc_chat" / "SESSION_LOG.md"
153
154
155SESSION_LOG_PATH = _session_log_path()
156
157
158def append_session_log(ctx: "Context") -> None:
159 """Append a record of the completed goal to ``SESSION_LOG.md``.
160
161 **NEVER touches ``CHANGELOG.md``** — that file is registry-generated
162 (``roadmap render``) and ``render --check``-gated. A test pins this.
163
164 Follows the formatting rules defined in
165 ``oc_chat/docs/CHANGELOG-format.md`` (Rule 4 H2 timestamp, Rule 9
166 directory-style session link) and adopts the Keep-a-Changelog
167 ``### Changed`` H3 vocabulary used by ``oct git changelog`` so a
168 file edited by both tools stays structurally consistent (Phase
169 F.2c). A new section is prepended after the metadata block:
170
171 ## YYYY-MM-DD:HH:MM:SS — <scope>
172 **Scope:** <scope>
173 **Session:** [`<name>`](oc_chat/sessions/<name>/)
174
175 ### Changed
176
177 - `path/one`
178 - `path/two`
179 """
180 func = "append_session_log"
181 if not ctx.goal_files_changed:
182 return
183
184 now = datetime.now()
185 timestamp = now.strftime("%Y-%m-%d:%H:%M:%S")
186 session_name = ctx.session.name
187 session_link = f"oc_chat/sessions/{session_name}/"
188
189 # Build the new section. CHANGELOG-format Rule 4 puts the timestamp
190 # in the H2; the Keep-a-Changelog ``### Changed`` H3 matches
191 # ``oct.git.changelog._SECTION_ORDER`` so the two tools produce
192 # interleavable output.
193 scope_line = ctx.goal_description
194 lines: list[str] = []
195 lines.append(f"## {timestamp} — {scope_line}")
196 lines.append(f"**Scope:** {scope_line}")
197 lines.append(f"**Session:** [`{session_name}`]({session_link})")
198 lines.append("")
199 lines.append("### Changed")
200 lines.append("")
201 for f in sorted(ctx.goal_files_changed):
202 lines.append(f"- `{f}`")
203 lines.append("")
204
205 new_section = "\n".join(lines)
206
207 # Read existing changelog (or create one)
208 try:
209 with open(SESSION_LOG_PATH, "r", encoding="utf-8") as fh:
210 existing = fh.read()
211 except FileNotFoundError:
212 existing = (
213 f"# OC Chat — {_PROJECT_DESCRIPTION} — SESSION LOG\n\n"
214 f"**Version:** [{_project_version()}] - {timestamp}**\n"
215 f"**Last Session:** [`{session_name}`]({session_link})\n\n"
216 )
217
218 # Normalise line-endings to \n for processing
219 existing_norm = existing.replace("\r\n", "\n")
220 lines_existing = existing_norm.split("\n")
221
222 # Find where the metadata block ends: after the Last Session line
223 meta_end = 0
224 for i, line in enumerate(lines_existing):
225 if line.startswith("**Last Session:**"):
226 meta_end = i + 1
227 break
228 if line.startswith("**Version:**"):
229 meta_end = i + 1 # fallback if no Last Session line
230
231 if meta_end == 0:
232 meta_end = min(3, len(lines_existing))
233
234 # Rebuild: metadata + updated Last Session + new section + rest of body
235 metadata_lines = lines_existing[:meta_end]
236
237 # Update the Last Session line
238 new_last_session = f"**Last Session:** [`{session_name}`]({session_link})"
239 updated_meta: list[str] = []
240 last_session_replaced = False
241 for line in metadata_lines:
242 if line.startswith("**Last Session:**"):
243 updated_meta.append(new_last_session)
244 last_session_replaced = True
245 elif line.startswith("**Version:**"):
246 updated_meta.append(line)
247 else:
248 updated_meta.append(line)
249
250 if not last_session_replaced:
251 inserted = False
252 final_meta: list[str] = []
253 for line in updated_meta:
254 final_meta.append(line)
255 if not inserted and line.startswith("**Version:**"):
256 final_meta.append(new_last_session)
257 inserted = True
258 updated_meta = final_meta
259
260 # Body: everything after metadata
261 body_lines = lines_existing[meta_end:]
262
263 # Assemble: metadata, 2 blank lines, new section, existing body
264 result_lines = updated_meta + [""] + [new_section]
265 body_has_content = any(line.strip() for line in body_lines)
266 if body_has_content:
267 result_lines.append("")
268 first_nonblank = 0
269 while first_nonblank < len(body_lines) and not body_lines[first_nonblank].strip():
270 first_nonblank += 1
271 result_lines.extend(body_lines[first_nonblank:])
272
273 result = "\n".join(result_lines)
274
275 # Atomic write (SOL18-03): a plain "w" truncates in place, so a crash mid-write
276 # destroys every prior entry. Write a temp file in the same dir, then os.replace
277 # (atomic on both POSIX and Windows). Does not add a cross-process lock — a
278 # concurrent-writer lock is a tracked follow-up; this closes the truncation path.
279 SESSION_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
280 tmp = SESSION_LOG_PATH.with_name(SESSION_LOG_PATH.name + f".tmp.{os.getpid()}")
281 try:
282 with open(tmp, "w", encoding="utf-8") as fh:
283 fh.write(result)
284 fh.flush()
285 os.fsync(fh.fileno())
286 os.replace(tmp, SESSION_LOG_PATH)
287 finally:
288 if tmp.exists():
289 tmp.unlink()
290
291 _dbg("OC_CHAT.MAIN", func,
292 f"updated {len(ctx.goal_files_changed)} file(s) for goal: {scope_line!r}", 2)
293 ctx.presenter.show_info(
294 f"SESSION_LOG.md updated — {len(ctx.goal_files_changed)} file(s) changed."
295 )
None append_session_log("Context" ctx)