oc_chat
Loading...
Searching...
No Matches
plan_header.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/render/plan_header.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_render_plan_header.py"
6"""
7Renderer for the registry-owned header block of plan Markdown files.
8
9Purpose
10-------
11Materialise current registry metadata into the leading header block of plan
12Markdown files while preserving the authored body.
13
14Responsibilities
15----------------
16- Render deterministic branch/worktree/status/assignment metadata.
17- Replace the leading H1/header block without touching the body section.
18- Write plan files through the shared atomic artifact helper.
19
20Diagnostics
21-----------
22Domain: OC_CHAT.MAIN
23Levels:
24 L2 - plan-header render lifecycle events
25 L3 - artifact drift details
26 L4 - header projection tracing
27
28Contracts
29---------
30- The first `##` section and everything after it is preserved.
31- Missing plan ids raise `KeyError`.
32
33Dependencies
34------------
35- oc_diagnostics (structured debug logging via _dbg)
36- oc_chat.registry.render._common (projection and write helpers)
37- oc_chat.registry.api (Registry facade the plan rows are read from)
38"""
39from __future__ import annotations
40
41from pathlib import Path
42
43from oc_diagnostics import _dbg
44
45from oc_chat.registry.api import Registry
47 RenderResult,
48 fetch_plan_rows,
49 slot_label,
50 write_text_artifact,
51)
52
53
54_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
55
56
57def render_plan_header(registry: Registry, *, plan_id: str) -> str:
58 """Render a deterministic Markdown header for ``plan_id``."""
59
60 row = _plan_row(registry, plan_id)
61 if row is None:
62 raise KeyError(f"plan not found: {plan_id}")
63 title = str(row.get("title") or plan_id)
64 lines = [
65 f"# {title}",
66 "",
67 f"**Plan ID:** `{row['plan_id']}`",
68 f"**Branch:** `{row.get('branch') or ''}`",
69 f"**Worktree:** `{row.get('worktree') or ''}`",
70 f"**Plan Type:** `{row.get('plan_type') or 'feature'}`",
71 f"**Status:** `{row.get('status')}`",
72 f"**Assigned Slot:** `{slot_label(row.get('assigned_slot'))}`",
73 "",
74 ]
75 return "\n".join(lines)
76
77
79 registry: Registry,
80 *,
81 plan_id: str,
82 plan_file: Path,
83 prefer_local: bool = False,
84 dry_run: bool = False,
85) -> RenderResult:
86 """Replace the leading header block of ``plan_file``."""
87
88 header = render_plan_header(registry, plan_id=plan_id)
89 if plan_file.exists():
90 body = _body_without_header(plan_file.read_text(encoding="utf-8"))
91 else:
92 body = ""
93 text = f"{header}{body.lstrip()}"
94 return write_text_artifact(
95 plan_file,
96 text,
97 artifact_kind="plan_header",
98 prefer_local=prefer_local,
99 dry_run=dry_run,
100 )
101
102
103def _body_without_header(text: str) -> str:
104 normalized = text.replace("\r\n", "\n").replace("\r", "\n")
105 lines = normalized.splitlines()
106 if not lines or not lines[0].startswith("# "):
107 return normalized
108 for index, line in enumerate(lines[1:], start=1):
109 if line.startswith("## "):
110 return "\n".join(lines[index:]).rstrip() + "\n"
111 return ""
112
113
114def _plan_row(registry: Registry, plan_id: str) -> dict[str, object] | None:
115 for row in fetch_plan_rows(registry):
116 if str(row["plan_id"]) == plan_id:
117 return row
118 return None
str render_plan_header(Registry registry, *, str plan_id)
dict[str, object]|None _plan_row(Registry registry, str plan_id)
RenderResult write_plan_header(Registry registry, *, str plan_id, Path plan_file, bool prefer_local=False, bool dry_run=False)