7One reader for a plan file's header conventions.
11Plan 257 P2 (A4). A plan file states its branch, worktree, title and
12type in a mix of YAML frontmatter and ``**Name:**`` markdown headers,
13and the dispatch layer used to parse that in three separate places: the
14``**Branch:**`` / ``**Worktree:**`` regexes and the frontmatter path in
15``scripts/plan_manage.py``, and the ``# `` / ``**Plan Type:**`` line
16scan in ``scripts/plan_status.py``. Three copies of a parser drift.
17This module is the one place, and the two call sites now go through it.
21- :func:`split_frontmatter` -- the ``---`` fence split, moved here from
22 ``scripts/plan_status.py`` so the format has one splitter in the
23 dispatch layer rather than one per consumer.
24- :class:`PlanMeta` -- the four header fields plus the parsed
25 frontmatter and, when it failed to parse, the error that caused it.
26- :class:`PlanMetadataProvider` -- the contract, so a caller can supply
28- :class:`DefaultPlanMetadataProvider` -- today's three parsers,
29 consolidated, field for field.
35 L2 - lifecycle events (a file was read)
36 L3 - semantic details (which lane a field came from)
37 L4 - deep tracing (module load, per-field parse)
41- The default provider is BYTE-IDENTICAL to the parsers it replaces,
42 per field and including their disagreements. ``branch`` and
43 ``worktree`` prefer a truthy frontmatter scalar, fall back to an
44 unanchored markdown regex, and are returned stripped of whitespace
45 and backticks, possibly empty. ``title`` and ``plan_type`` come from
46 a whole-file line scan that strips neither backticks nor trailing
47 prose, and whose repeat-match precedence depends on ``plan_id``.
48 Preserving those quirks is deliberate: plan 257 P6 forbids a
49 behaviour change here, and the quirks are reported instead.
50- A malformed ``---`` fence yields empty frontmatter, as it always did.
51 Invalid YAML is NOT swallowed: it is returned in
52 :attr:`PlanMeta.frontmatter_error` so a caller that used to let it
53 escape can still let it escape, and a caller that never parsed YAML
54 at all can still ignore it.
55- Callers own their own fallbacks. This module never synthesises a
56 branch, a worktree or a title.
60- oc_diagnostics (structured debug logging via _dbg)
61- yaml (frontmatter parse)
64from __future__
import annotations
67from collections.abc
import Mapping
68from dataclasses
import dataclass, field
69from pathlib
import Path
70from typing
import Protocol
74from oc_diagnostics
import _dbg
76_dbg(
"OC_CHAT.MAIN",
"module",
"plan_metadata loaded", 4)
83_BRANCH_RE = re.compile(
r"\*\*Branch:\*\*\s*`?(?P<value>[^\s`]+)", re.MULTILINE)
84_WORKTREE_RE = re.compile(
r"\*\*Worktree:\*\*\s*(?P<value>.+)", re.MULTILINE)
87_PLAN_TYPE_PREFIX =
"**Plan Type:**"
90@dataclass(frozen=True)
92 """A plan file's header fields, as its authors actually wrote them."""
100 title: str |
None =
None
102 plan_type: str |
None =
None
104 frontmatter: Mapping[str, object] = field(default_factory=dict)
106 frontmatter_error: Exception |
None =
None
110 """Reads a plan file's header fields."""
112 def metadata(self, plan_path: Path, *, plan_id: str |
None =
None) -> PlanMeta:
113 """Read *plan_path* and return its header fields."""
116 """Return the header fields of an already-read plan file."""
120 """Split text into ``(yaml_frontmatter, markdown_body)``.
122 Moved from ``scripts/plan_status.py`` (plan 257 P2), which still
123 exposes it under its old private name for the two callers that
126 func =
"split_frontmatter"
127 lines = text.splitlines(keepends=
True)
128 if not lines
or lines[0].rstrip(
"\r\n") !=
"---":
129 raise ValueError(
"missing opening '---' delimiter")
131 for i
in range(1, len(lines)):
132 if lines[i].rstrip(
"\r\n") ==
"---":
136 raise ValueError(
"missing closing '---' delimiter")
137 frontmatter =
"".join(lines[1:end])
138 body =
"".join(lines[end + 1 :]).lstrip(
"\n")
139 _dbg(
"OC_CHAT.MAIN", func, f
"frontmatter {len(frontmatter)}B body {len(body)}B", 4)
140 return frontmatter, body
144 """First match of *pattern*, stripped of whitespace and backticks."""
145 match = pattern.search(text)
148 return match.group(
"value").strip().strip(
"`").strip()
151def _scan_headings(text: str, plan_id: str |
None) -> tuple[str |
None, str |
None]:
152 """The ``# `` title and ``**Plan Type:**`` value, in one pass.
154 One pass, with one shared stop condition, because that is what the
155 pre-257 loop did and the stop condition is observable: it compares
156 the title against *plan_id*, so repeat-match precedence differs
157 depending on whether the heading happens to equal the plan id.
159 func =
"_scan_headings"
160 title: str |
None =
None
161 plan_type: str |
None =
None
162 for line
in text.splitlines():
163 stripped = line.strip()
164 if stripped.startswith(_TITLE_PREFIX):
165 title = stripped[len(_TITLE_PREFIX) :].strip()
or None
166 if stripped.startswith(_PLAN_TYPE_PREFIX):
167 plan_type = stripped.split(_PLAN_TYPE_PREFIX, 1)[1].strip()
or None
168 if title
is not None and title != plan_id
and plan_type
is not None:
170 _dbg(
"OC_CHAT.MAIN", func, f
"title={title!r} plan_type={plan_type!r}", 4)
171 return title, plan_type
175 """The three pre-257 parsers, consolidated without changing a verdict."""
177 def metadata(self, plan_path: Path, *, plan_id: str |
None =
None) -> PlanMeta:
178 """Read *plan_path* as UTF-8 and parse its header."""
179 func =
"DefaultPlanMetadataProvider.metadata"
180 text = Path(plan_path).read_text(encoding=
"utf-8")
181 _dbg(
"OC_CHAT.MAIN", func, f
"read {plan_path} ({len(text)}B)", 2)
185 """Parse *text*; reads nothing from disk."""
187 branch = str(frontmatter.get(
"branch")
or _markdown_value(text, _BRANCH_RE)
or "").strip()
189 frontmatter.get(
"worktree")
or _markdown_value(text, _WORKTREE_RE)
or ""
197 frontmatter=frontmatter,
198 frontmatter_error=error,
203 """The frontmatter mapping, and the YAML error that stopped it.
205 The ``ValueError`` arm is the pre-257 "no frontmatter -- markdown-form
206 plan" path. The ``YAMLError`` arm is new only in that the error is
207 returned rather than raised; ``plan_manage`` re-raises it, so the
208 caller that used to see it still does, and ``plan_status``, which
209 never parsed YAML here at all, is unaffected by a plan whose prose
210 frontmatter is not strict YAML.
212 func =
"_parse_frontmatter"
215 loaded = yaml.safe_load(raw)
217 _dbg(
"OC_CHAT.MAIN", func,
"no frontmatter fence; markdown-form plan", 3)
219 except yaml.YAMLError
as exc:
222 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: frontmatter: {exc}", 3)
224 if isinstance(loaded, dict):
234 """*provider*, or the module default when it is ``None``."""
235 return provider
if provider
is not None else DEFAULT_PROVIDER
240 "DefaultPlanMetadataProvider",
242 "PlanMetadataProvider",