plan-doc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: plan-doc
3
+ Version: 0.1.0
4
+ Summary: Typed parser for plan-file YAML frontmatter (schema v1) — strict validation, first-block-only extraction, foreign-document discrimination
5
+ License: MIT
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.10
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
13
+ Classifier: Development Status :: 4 - Beta
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pyyaml>=6.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=9.0.3; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # plan-doc
23
+
24
+ Typed parser for plan-file YAML frontmatter (schema v1). Extracts the
25
+ machine-readable fields a plan-driven-PR workflow needs — `template`,
26
+ `disposal`, `branch`, a planner-handoff `dossier` block — from markdown
27
+ plan documents, with strict validation and loud failures.
28
+
29
+ ```python
30
+ from pathlib import Path
31
+ from plan_doc import PlanDoc, PlanDocError, NonDraftPrFrontmatterError
32
+
33
+ doc = PlanDoc.from_path(Path("docs/plans/my-plan.md"))
34
+ if doc.disposal == "delete-on-merge":
35
+ ...
36
+ if doc.dossier is not None:
37
+ files = doc.dossier.get("files", [])
38
+ ```
39
+
40
+ ## What it enforces
41
+
42
+ - **Schema v1.** `plan_schema: 1` is required; `template`, `disposal`,
43
+ `phase`, `cleanup_trigger`, `ticket`, `branch`, `tdd_mode`, `dossier`
44
+ are the only other top-level keys. Unknown keys raise `PlanDocError` —
45
+ this is the typo guard (`dispozal` fails loud).
46
+ - **First-block-only parsing.** Only a `---`...`---` block at the very
47
+ top of the file is frontmatter. Body-level `---` horizontal rules are
48
+ never split on.
49
+ - **Foreign-document discrimination.** Valid frontmatter with no
50
+ `plan_schema` and no plan indicator fields raises the typed subclass
51
+ `NonDraftPrFrontmatterError`, so callers can treat non-plan documents
52
+ leniently without swallowing real validation errors.
53
+ - **No frontmatter at all** raises `PlanDocError` with an actionable
54
+ message (add `plan_schema: 1`).
55
+
56
+ ## Dependency note
57
+
58
+ `pyyaml` is a hard dependency, and the module *also* keeps a minimal
59
+ no-yaml fallback parser (`_parse_yaml_minimal`) for simple `key: value`
60
+ frontmatter. Both halves are deliberate: the upstream source of this
61
+ module runs in a zero-pip environment and relies on the fallback, while
62
+ the package declares full functionality. The fallback is covered by
63
+ tests here — don't remove either half.
64
+
65
+ ## Provenance
66
+
67
+ Extracted verbatim (code byte-identical, module docstring reframed) from
68
+ the draft-pr skill of the m0j0d portfolio plugin, where the vendored copy
69
+ remains the operational source of truth. This package mirrors it at
70
+ release points.
71
+
72
+ Pool purity rule: transport-free, LLM-agnostic, no secrets. Pure parsing;
73
+ the only I/O is `Path.read_text` in `PlanDoc.from_path`.
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,6 @@
1
+ plan_doc.py,sha256=_mt8zPveaop9xNGy74j4swpFBCwW7F65x8jCvUzM9cA,19116
2
+ plan_doc-0.1.0.dist-info/licenses/LICENSE,sha256=AHOl9dkJ8tFtExkZkApUx1vccbMmXPw7i8AhZqSAl6U,1062
3
+ plan_doc-0.1.0.dist-info/METADATA,sha256=m7vaRGgHuVRtR_koYSUQy6KKzP0CgOcjjyuzs7xvv_g,3023
4
+ plan_doc-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ plan_doc-0.1.0.dist-info/top_level.txt,sha256=2kQZdRpU55PG0m2UVh0zLsr9ZqTkGEkK5cS-CdxjS9o,9
6
+ plan_doc-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 m0j0d
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ plan_doc
plan_doc.py ADDED
@@ -0,0 +1,507 @@
1
+ """plan_doc — typed parser for plan-file YAML frontmatter (schema v1).
2
+
3
+ Provenance: extracted verbatim from the draft-pr skill of the m0j0d
4
+ portfolio plugin (``plugin/skills/draft-pr/scripts/plan_doc.py``), where it
5
+ is the single source of truth for extracting structured fields from
6
+ plan-driven-PR documents. The vendored plugin copy remains operational
7
+ there; this package mirrors it at release points.
8
+
9
+ Design decisions:
10
+ - YAML frontmatter for machine-readable fields; markdown body for human content.
11
+ - First-`---`-block-only parser: extracts ONLY the first ``---``...``---`` block
12
+ at the very top of the file. Does NOT split on body-level ``---`` horizontal
13
+ rules (common as horizontal rules in real-world plan bodies).
14
+ - When NO frontmatter block is present, raises ``PlanDocError`` with an actionable
15
+ message directing the author to add a ``plan_schema: 1`` frontmatter block.
16
+ Unmigrated files fail loud.
17
+ - When a frontmatter block IS present, validation is strict: missing required
18
+ fields and unknown fields both raise ``PlanDocError``.
19
+
20
+ Schema v1 fields:
21
+ plan_schema int required when block present; must equal 1
22
+ template str optional; values: "minimal", "standard"
23
+ disposal str optional; values: "delete-on-merge", "deferred"
24
+ phase str optional
25
+ cleanup_trigger str optional
26
+ ticket str optional
27
+ branch str optional
28
+ tdd_mode str optional
29
+ dossier dict optional; Thinker-Doer handoff block (schema
30
+ defined by the source portfolio's
31
+ thinker-doer-dossier-handoff pattern). Lightly
32
+ validated: must be a mapping; recognized sub-keys
33
+ (files, symbols, patterns, exclusions, not_in_scope,
34
+ open_questions, escape_log, hot_spans,
35
+ thinker_tokens_used) must have the right container
36
+ type when present.
37
+
38
+ Ad-hoc top-level key policy:
39
+ ONLY the fields listed above are allowed as top-level frontmatter keys.
40
+ Ad-hoc keys like ``owner``, ``created``, ``revised``, ``sequencing``,
41
+ ``problem_one_liner``, ``status`` etc. are NOT whitelisted and raise
42
+ ``PlanDocError``. Rationale: preserves the typo guard (``dispozal`` must
43
+ still raise) and the "plans don't invent top-level keys" discipline.
44
+ The ``dossier:`` block is the designed mechanism for planner metadata;
45
+ ad-hoc keys belong inside it or in the plan body.
46
+
47
+ Usage::
48
+
49
+ from plan_doc import PlanDoc, PlanDocError, NonDraftPrFrontmatterError
50
+
51
+ doc = PlanDoc.from_path(Path("docs/plans/my-plan.md"))
52
+ if doc.template == "minimal":
53
+ ...
54
+ if doc.disposal == "delete-on-merge":
55
+ ...
56
+ if doc.dossier is not None:
57
+ files = doc.dossier.get("files", [])
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ from dataclasses import dataclass, field
63
+ from pathlib import Path
64
+ from typing import Optional
65
+
66
+ try:
67
+ import yaml as _yaml
68
+ _YAML_AVAILABLE = True
69
+ except ImportError:
70
+ _YAML_AVAILABLE = False
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Error types
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ class PlanDocError(ValueError):
79
+ """Raised when a plan frontmatter block is present but malformed or invalid."""
80
+
81
+
82
+ class NonDraftPrFrontmatterError(PlanDocError):
83
+ """Raised when frontmatter is present but belongs to a foreign (non-draft-pr)
84
+ document schema — i.e. it has NO ``plan_schema`` key AND contains NONE of the
85
+ known draft-pr indicator fields.
86
+
87
+ Callers catch this specific subclass and construct a field-less ``PlanDoc``
88
+ (``template=None``, ``disposal=None``, body intact) — foreign documents get
89
+ standard-template treatment and no disposal step. All other ``PlanDocError``
90
+ subclasses propagate as strict validation failures.
91
+ """
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Schema constants
96
+ # ---------------------------------------------------------------------------
97
+
98
+ _SUPPORTED_SCHEMA_VERSIONS = {1}
99
+ _ALLOWED_DISPOSAL_VALUES = {"delete-on-merge", "deferred"}
100
+ _KNOWN_FIELDS = frozenset({
101
+ "plan_schema",
102
+ "template",
103
+ "disposal",
104
+ "phase",
105
+ "cleanup_trigger",
106
+ "ticket",
107
+ "branch",
108
+ "tdd_mode",
109
+ # Thinker-Doer handoff block (atlas/patterns/thinker-doer-dossier-handoff.md).
110
+ # Recognized and lightly validated; see _validate_dossier().
111
+ "dossier",
112
+ })
113
+
114
+ # Sub-keys of the dossier block that must be lists when present.
115
+ # Extra sub-keys beyond this set are allowed (lenient validation — the pattern
116
+ # permits keys like hot_spans, thinker_tokens_used without a schema version bump).
117
+ _DOSSIER_LIST_KEYS = frozenset({
118
+ "files",
119
+ "symbols",
120
+ "patterns",
121
+ "exclusions",
122
+ "not_in_scope",
123
+ "open_questions",
124
+ "escape_log",
125
+ })
126
+
127
+ # Fields (excluding plan_schema itself) that positively identify a draft-pr
128
+ # document. A frontmatter block that has NONE of these AND no plan_schema is
129
+ # treated as a "foreign" document (infra-099-style, loki-exec-light-style) and
130
+ # raises NonDraftPrFrontmatterError, which callers tolerate as a field-less PlanDoc.
131
+ _DRAFT_PR_INDICATOR_FIELDS = frozenset({
132
+ "template",
133
+ "disposal",
134
+ "phase",
135
+ "cleanup_trigger",
136
+ "ticket",
137
+ "branch",
138
+ "tdd_mode",
139
+ })
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # PlanDoc dataclass
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ @dataclass
148
+ class PlanDoc:
149
+ """Typed representation of a /draft-pr plan document.
150
+
151
+ Fields are populated from YAML frontmatter. When no frontmatter block is
152
+ present, ``from_text`` / ``from_path`` raise ``PlanDocError`` with an
153
+ actionable message directing the author to add ``plan_schema: 1``.
154
+
155
+ Foreign-schema docs (valid YAML, no ``plan_schema``, no draft-pr indicator
156
+ fields) raise ``NonDraftPrFrontmatterError`` — callers catch this specific
157
+ subclass and construct a field-less ``PlanDoc(template=None, disposal=None,
158
+ body=text)`` for standard-template treatment with no disposal step.
159
+
160
+ The raw markdown body (everything after the closing frontmatter ``---``) is
161
+ available as ``.body``.
162
+ """
163
+
164
+ # Schema version — required when frontmatter block is present
165
+ plan_schema: Optional[int] = None
166
+
167
+ # Core lifecycle fields
168
+ template: Optional[str] = None
169
+ disposal: Optional[str] = None
170
+
171
+ # Optional metadata
172
+ phase: Optional[str] = None
173
+ cleanup_trigger: Optional[str] = None
174
+ ticket: Optional[str] = None
175
+ branch: Optional[str] = None
176
+ tdd_mode: Optional[str] = None
177
+
178
+ # Thinker-Doer handoff block (atlas/patterns/thinker-doer-dossier-handoff.md).
179
+ # None when not present in frontmatter; populated dict when present.
180
+ dossier: Optional[dict] = field(default=None, repr=False)
181
+
182
+ # Raw body text (everything after the frontmatter block, or full text for
183
+ # legacy plans)
184
+ body: str = field(default="", repr=False)
185
+
186
+ # Internal flag: True when loaded from frontmatter (strict mode),
187
+ # False when constructed directly (e.g. field-less PlanDoc for foreign docs)
188
+ _from_frontmatter: bool = field(default=False, repr=False)
189
+
190
+ # -----------------------------------------------------------------------
191
+ # Factory methods
192
+ # -----------------------------------------------------------------------
193
+
194
+ @classmethod
195
+ def from_path(cls, path: Path) -> PlanDoc:
196
+ """Load a PlanDoc from a file on disk.
197
+
198
+ Parameters
199
+ ----------
200
+ path:
201
+ Absolute path to the plan markdown file.
202
+
203
+ Returns
204
+ -------
205
+ PlanDoc
206
+ Populated from frontmatter (strict validation).
207
+
208
+ Raises
209
+ ------
210
+ PlanDocError
211
+ When no frontmatter block is present, or when the frontmatter block
212
+ is malformed, has missing required fields, has invalid field values,
213
+ or has unknown fields.
214
+ FileNotFoundError
215
+ When the file does not exist.
216
+ """
217
+ text = Path(path).read_text(encoding="utf-8")
218
+ try:
219
+ return cls.from_text(text)
220
+ except PlanDocError as exc:
221
+ # Re-raise with file path context if not already present.
222
+ # Use type(exc)(...) to preserve the exact subclass (e.g.
223
+ # NonDraftPrFrontmatterError) so callers that discriminate on
224
+ # the subclass work identically whether they call from_path or
225
+ # from_text.
226
+ msg = str(exc)
227
+ if str(path) not in msg and Path(path).name not in msg:
228
+ raise type(exc)(f"{path}: {msg}") from exc
229
+ raise
230
+
231
+ @classmethod
232
+ def from_text(cls, text: str) -> PlanDoc:
233
+ """Load a PlanDoc from raw plan text.
234
+
235
+ Parameters
236
+ ----------
237
+ text:
238
+ Raw content of the plan markdown file.
239
+
240
+ Returns
241
+ -------
242
+ PlanDoc
243
+ Populated from frontmatter (strict validation).
244
+
245
+ Raises
246
+ ------
247
+ PlanDocError
248
+ When no frontmatter block is present at the top of the file, or
249
+ when the frontmatter block is present but malformed or invalid.
250
+ Unmigrated plans (no leading ``---`` block) raise with an actionable
251
+ message: add ``plan_schema: 1``; see references/frontmatter-schema.md.
252
+ """
253
+ fm_data, body = _extract_frontmatter(text)
254
+ if fm_data is not None:
255
+ return cls._from_frontmatter_dict(fm_data, body)
256
+ else:
257
+ raise PlanDocError(
258
+ "No frontmatter block found at the top of this plan file. "
259
+ "Add a 'plan_schema: 1' frontmatter block to the top of the file. "
260
+ "See references/frontmatter-schema.md for the full schema."
261
+ )
262
+
263
+ # -----------------------------------------------------------------------
264
+ # Internal construction
265
+ # -----------------------------------------------------------------------
266
+
267
+ @classmethod
268
+ def _from_frontmatter_dict(
269
+ cls, data: dict, body: str
270
+ ) -> PlanDoc:
271
+ """Construct a PlanDoc from a parsed frontmatter dict (strict mode).
272
+
273
+ Raises NonDraftPrFrontmatterError when the frontmatter block looks like a
274
+ foreign (non-draft-pr) document: no ``plan_schema`` key AND none of the
275
+ known draft-pr indicator fields present. This lets callers fall back to
276
+ legacy regex extraction for infrastructure plans (infra-099-style,
277
+ loki-exec-light-style) without silently swallowing real validation errors.
278
+
279
+ Raises PlanDocError on:
280
+ - missing required field ``plan_schema`` (when draft-pr indicator fields
281
+ ARE present — this is a malformed draft-pr plan, not a foreign doc)
282
+ - unsupported ``plan_schema`` value
283
+ - invalid ``disposal`` value
284
+ - unknown fields (when plan_schema is present)
285
+ """
286
+ # Foreign-document discriminator: no plan_schema AND no draft-pr indicator
287
+ # fields → this is not a draft-pr plan at all (e.g. infra-099, loki-exec).
288
+ # Raise the typed subclass so callers can catch ONLY this case.
289
+ has_plan_schema = "plan_schema" in data
290
+ has_indicator_fields = bool(set(data.keys()) & _DRAFT_PR_INDICATOR_FIELDS)
291
+ if not has_plan_schema and not has_indicator_fields:
292
+ raise NonDraftPrFrontmatterError(
293
+ "Frontmatter block present but contains no 'plan_schema' key and "
294
+ "no draft-pr indicator fields — this appears to be a non-draft-pr "
295
+ "document. Known draft-pr fields: "
296
+ f"{sorted(_DRAFT_PR_INDICATOR_FIELDS | {'plan_schema'})}"
297
+ )
298
+
299
+ # Unknown field check (only for plans that have at least one draft-pr marker)
300
+ unknown = set(data.keys()) - _KNOWN_FIELDS
301
+ if unknown:
302
+ names = ", ".join(sorted(unknown))
303
+ raise PlanDocError(
304
+ f"Unknown frontmatter field(s): {names}. "
305
+ f"Known fields: {sorted(_KNOWN_FIELDS)}"
306
+ )
307
+
308
+ # plan_schema is required when frontmatter block is present and we know
309
+ # this is a draft-pr plan (indicator fields are present)
310
+ if not has_plan_schema:
311
+ raise PlanDocError(
312
+ "Frontmatter block present but 'plan_schema' field is missing. "
313
+ "Add 'plan_schema: 1' to the frontmatter block."
314
+ )
315
+
316
+ schema_version = data["plan_schema"]
317
+ if schema_version not in _SUPPORTED_SCHEMA_VERSIONS:
318
+ raise PlanDocError(
319
+ f"Unsupported plan_schema value: {schema_version!r}. "
320
+ f"Supported versions: {sorted(_SUPPORTED_SCHEMA_VERSIONS)}"
321
+ )
322
+
323
+ # Validate disposal value if provided
324
+ disposal = data.get("disposal")
325
+ if disposal is not None and disposal not in _ALLOWED_DISPOSAL_VALUES:
326
+ raise PlanDocError(
327
+ f"Invalid disposal value: {disposal!r}. "
328
+ f"Allowed values: {sorted(_ALLOWED_DISPOSAL_VALUES)}"
329
+ )
330
+
331
+ # Validate and extract dossier block if present
332
+ dossier = data.get("dossier")
333
+ if dossier is not None:
334
+ _validate_dossier(dossier)
335
+
336
+ return cls(
337
+ plan_schema=schema_version,
338
+ template=data.get("template"),
339
+ disposal=disposal,
340
+ phase=data.get("phase"),
341
+ cleanup_trigger=data.get("cleanup_trigger"),
342
+ ticket=data.get("ticket"),
343
+ branch=data.get("branch"),
344
+ tdd_mode=data.get("tdd_mode"),
345
+ dossier=dossier,
346
+ body=body,
347
+ _from_frontmatter=True,
348
+ )
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Frontmatter extraction (first-block-only parser)
353
+ # ---------------------------------------------------------------------------
354
+
355
+
356
+ def _extract_frontmatter(text: str) -> tuple[Optional[dict], str]:
357
+ """Extract the first ``---``...``---`` YAML frontmatter block from text.
358
+
359
+ Rules:
360
+ - The file MUST begin with a ``---`` line (optionally with trailing
361
+ whitespace) for a frontmatter block to be recognized.
362
+ - The parser extracts ONLY the first such block. Everything after the
363
+ closing ``---`` line is returned verbatim as the body.
364
+ - Does NOT split on body-level ``---`` horizontal rules — those are part
365
+ of the body.
366
+ - Returns (None, text) when no frontmatter block is present at the top.
367
+
368
+ Parameters
369
+ ----------
370
+ text:
371
+ Raw file content.
372
+
373
+ Returns
374
+ -------
375
+ (dict, str) when a frontmatter block is found and parsed.
376
+ (None, text) when no frontmatter block is present.
377
+
378
+ Raises
379
+ ------
380
+ PlanDocError
381
+ When a frontmatter block opening ``---`` is found at the top but the
382
+ YAML inside is malformed or the closing ``---`` is never found.
383
+ """
384
+ lines = text.splitlines(keepends=True)
385
+ if not lines:
386
+ return None, text
387
+
388
+ # The first line must be exactly "---" (with optional trailing whitespace)
389
+ first_line = lines[0].rstrip()
390
+ if first_line != "---":
391
+ return None, text
392
+
393
+ # Find the closing --- (starting from line 1, skipping the opening ---)
394
+ closing_idx = None
395
+ for i in range(1, len(lines)):
396
+ if lines[i].rstrip() == "---":
397
+ closing_idx = i
398
+ break
399
+
400
+ if closing_idx is None:
401
+ raise PlanDocError(
402
+ "Frontmatter block opened with '---' but no closing '---' found."
403
+ )
404
+
405
+ # Extract the YAML content between the two --- markers
406
+ fm_lines = lines[1:closing_idx]
407
+ fm_text = "".join(fm_lines)
408
+
409
+ # Parse the YAML
410
+ data = _parse_yaml(fm_text)
411
+
412
+ # Everything after the closing --- is the body
413
+ body_lines = lines[closing_idx + 1:]
414
+ body = "".join(body_lines)
415
+
416
+ return data, body
417
+
418
+
419
+ def _parse_yaml(text: str) -> dict:
420
+ """Parse YAML text and return a dict.
421
+
422
+ Uses PyYAML when available; falls back to a minimal parser for simple
423
+ key: value pairs.
424
+
425
+ Raises
426
+ ------
427
+ PlanDocError
428
+ When the YAML is malformed.
429
+ """
430
+ if _YAML_AVAILABLE:
431
+ try:
432
+ result = _yaml.safe_load(text)
433
+ except _yaml.YAMLError as exc:
434
+ raise PlanDocError(f"Malformed YAML in frontmatter: {exc}") from exc
435
+ if result is None:
436
+ return {}
437
+ if not isinstance(result, dict):
438
+ raise PlanDocError(
439
+ f"Frontmatter YAML must be a mapping (dict), got {type(result).__name__}"
440
+ )
441
+ return result
442
+ else:
443
+ # Minimal fallback parser for simple key: value pairs
444
+ return _parse_yaml_minimal(text)
445
+
446
+
447
+ def _parse_yaml_minimal(text: str) -> dict:
448
+ """Minimal YAML parser for simple ``key: value`` pairs (no PyYAML fallback).
449
+
450
+ Handles only scalar values. Raises PlanDocError on anything complex.
451
+ """
452
+ result = {}
453
+ for line_num, line in enumerate(text.splitlines(), start=1):
454
+ line = line.strip()
455
+ if not line or line.startswith("#"):
456
+ continue
457
+ if ":" not in line:
458
+ raise PlanDocError(
459
+ f"Malformed YAML in frontmatter at line {line_num}: "
460
+ f"expected 'key: value', got {line!r}"
461
+ )
462
+ key, _, value = line.partition(":")
463
+ key = key.strip()
464
+ value = value.strip()
465
+ # Try to parse simple int values
466
+ if value.isdigit():
467
+ result[key] = int(value)
468
+ else:
469
+ # Strip surrounding quotes if present
470
+ if (value.startswith('"') and value.endswith('"')) or \
471
+ (value.startswith("'") and value.endswith("'")):
472
+ value = value[1:-1]
473
+ result[key] = value
474
+ return result
475
+
476
+
477
+ def _validate_dossier(dossier: object) -> None:
478
+ """Light structural validation of the dossier block.
479
+
480
+ Rules (lenient by design — the pattern allows extension without version bumps):
481
+ - ``dossier`` must be a mapping (dict), not a scalar or list.
482
+ - Each of the recognized list-typed sub-keys (``files``, ``symbols``,
483
+ ``patterns``, ``exclusions``, ``not_in_scope``, ``open_questions``,
484
+ ``escape_log``) must be a list when present.
485
+ - Extra sub-keys beyond the recognized set are allowed; over-constraining
486
+ would break plans that use valid pattern extensions.
487
+
488
+ Raises
489
+ ------
490
+ PlanDocError
491
+ When ``dossier`` is not a mapping, or a recognized list-typed sub-key
492
+ has the wrong type. The message names the offending sub-key.
493
+ """
494
+ if not isinstance(dossier, dict):
495
+ raise PlanDocError(
496
+ f"'dossier' frontmatter block must be a mapping (dict), "
497
+ f"got {type(dossier).__name__!r}"
498
+ )
499
+ for key in _DOSSIER_LIST_KEYS:
500
+ value = dossier.get(key)
501
+ if value is not None and not isinstance(value, list):
502
+ raise PlanDocError(
503
+ f"'dossier.{key}' must be a list, "
504
+ f"got {type(value).__name__!r}: {value!r}"
505
+ )
506
+
507
+