@xenonbyte/req-2-plan 0.2.3 → 0.4.0

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.
@@ -7,6 +7,7 @@ The Agent handles semantic quality; this module handles structural validation.
7
7
  from __future__ import annotations
8
8
 
9
9
  import re
10
+ from collections import Counter
10
11
  from dataclasses import dataclass, field
11
12
  from pathlib import Path
12
13
 
@@ -19,7 +20,15 @@ from tools.workflow_cli.models import (
19
20
  TierEstimate,
20
21
  TierModifier,
21
22
  )
23
+ from tools.workflow_cli.markdown import (
24
+ heading_bounded_bodies,
25
+ heading_level,
26
+ strip_readonly_sections,
27
+ unfenced_markdown_lines,
28
+ unfenced_markdown_text,
29
+ )
22
30
  from tools.workflow_cli.output import EXIT_GATE_FAIL
31
+ from tools.workflow_cli.stage_schema import PLAN_TASK_FIELD_RE, PLAN_TASK_FIELDS
23
32
 
24
33
  # ---------------------------------------------------------------------------
25
34
  # GateResult
@@ -82,11 +91,42 @@ def check_entry_gate(
82
91
  # Upstream ID reference pattern
83
92
  # ---------------------------------------------------------------------------
84
93
 
94
+ _FILL_IN_PLACEHOLDER_RE = re.compile(r"<!--\s*fill in\s*-->", re.IGNORECASE)
95
+
96
+ _PLACEHOLDER_PATTERNS = [
97
+ _FILL_IN_PLACEHOLDER_RE, # untouched template body
98
+ re.compile(
99
+ r"(?im)^\s*(?:[-*]\s*)?(?:[A-Za-z][A-Za-z0-9 /_-]*:\s*)?TBD\s*$"
100
+ ), # TBD as a line, field value, or list item
101
+ re.compile(r"(?im)^\s*(?:[-*]\s*)?maybe\s*$"),
102
+ re.compile(r"\bTODO later\b", re.IGNORECASE),
103
+ re.compile(
104
+ r"(?im)^\s*(?:[-*]\s*)?(?:[A-Za-z][A-Za-z0-9 /_-]*:\s*)?FIXME\s*$"
105
+ ), # FIXME as a placeholder line/field, not prose
106
+ ]
107
+
85
108
  # IDs that represent upstream references: REQ-*, RISK-*, DES-*, SPEC-*
86
109
  _UPSTREAM_ID_PATTERN = re.compile(
87
110
  r"\b(REQ-[A-Z]+-\d+|RISK-[A-Z]+-\d+|DES-[A-Z]+-\d+|SPEC-[A-Z]+-\d+)\b"
88
111
  )
89
112
 
113
+ # Trace-ID validation: well-formed vs candidate patterns
114
+ _VALID_TRACE_ID_RE = re.compile(
115
+ r"^(?:REQ|RISK|DES|SPEC)-[A-Z]+-\d+$|^SCOPE-(?:IN|OUT)-\d+$|^PLAN-TASK-\d+$"
116
+ )
117
+ _TRACE_ID_CANDIDATE_RE = re.compile(
118
+ r"\b(?:REQ|RISK|DES|SPEC)-[A-Za-z][A-Za-z0-9_-]*\d[A-Za-z0-9_-]*"
119
+ r"|\bSCOPE-(?:IN|OUT)-[A-Za-z0-9_-]*\d[A-Za-z0-9_-]*"
120
+ r"|\bPLAN-TASK-[A-Za-z0-9_-]*\d[A-Za-z0-9_-]*"
121
+ )
122
+
123
+ # Native-ID heading patterns: stages that MUST define at least one native ID in a heading
124
+ _STAGE_NATIVE_HEADING_PATTERNS = {
125
+ Stage.RISK_DISCOVERY: re.compile(r"(?m)^#+\s+.*\bRISK-[A-Z]+-\d+\b"),
126
+ Stage.DESIGN: re.compile(r"(?m)^#+\s+.*\bDES-[A-Z]+-\d+\b"),
127
+ Stage.SPEC: re.compile(r"(?m)^#+\s+.*\bSPEC-[A-Z]+-\d+\b"),
128
+ }
129
+
90
130
  # Closure status tags
91
131
  _CLOSURE_TAGS = frozenset(["[ADDRESSED]", "[DEFERRED]", "[N/A]", "[OUT-OF-SCOPE]", "[CLOSED]"])
92
132
 
@@ -97,7 +137,7 @@ _DEFINED_ID_PATTERN = re.compile(r"\b([A-Z]+-[A-Z]+-\d+)\b")
97
137
  def _find_defined_ids(content: str) -> set[str]:
98
138
  """Return IDs that are defined in headings (i.e. the current artifact is defining them)."""
99
139
  heading_pattern = re.compile(r"^#{1,6}\s+.*\b([A-Z]+-[A-Z]+-\d+)\b", re.MULTILINE)
100
- return set(heading_pattern.findall(content))
140
+ return set(heading_pattern.findall(unfenced_markdown_text(content)))
101
141
 
102
142
 
103
143
  def _find_ids_without_closure(content: str) -> list[str]:
@@ -106,7 +146,8 @@ def _find_ids_without_closure(content: str) -> list[str]:
106
146
  IDs defined in headings of the current artifact are excluded — they are
107
147
  being *defined* here, not referencing upstream artifacts that need closure.
108
148
  """
109
- all_refs = set(_UPSTREAM_ID_PATTERN.findall(content))
149
+ search_content = unfenced_markdown_text(content)
150
+ all_refs = set(_UPSTREAM_ID_PATTERN.findall(search_content))
110
151
  defined_here = _find_defined_ids(content)
111
152
  # Only check IDs that are referenced but NOT defined in this artifact
112
153
  refs_to_check = all_refs - defined_here
@@ -121,7 +162,7 @@ def _find_ids_without_closure(content: str) -> list[str]:
121
162
  re.escape(ref_id) + r"[^\n]*" + re.escape(tag),
122
163
  re.IGNORECASE,
123
164
  )
124
- if pattern.search(content):
165
+ if pattern.search(search_content):
125
166
  has_closure = True
126
167
  break
127
168
  if not has_closure:
@@ -132,7 +173,7 @@ def _find_ids_without_closure(content: str) -> list[str]:
132
173
  def _find_duplicate_ids(content: str) -> list[str]:
133
174
  """Return IDs that appear more than once in heading (definition) context."""
134
175
  heading_pattern = re.compile(r"^#{1,6}\s+.*\b([A-Z]+-[A-Z]+-\d+)\b", re.MULTILINE)
135
- heading_ids = heading_pattern.findall(content)
176
+ heading_ids = heading_pattern.findall(unfenced_markdown_text(content))
136
177
 
137
178
  from collections import Counter
138
179
  counts = Counter(heading_ids)
@@ -145,41 +186,9 @@ def _find_duplicate_ids(content: str) -> list[str]:
145
186
 
146
187
  _EXTERNAL_DOCS_RE = re.compile(r"^## External Documentation Checked\s*$", re.MULTILINE)
147
188
  _H2_RE = re.compile(r"^##\s+", re.MULTILINE)
148
- _MARKDOWN_CODE_FENCE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})", re.MULTILINE)
149
- _MARKDOWN_FENCE_MARKER_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})")
150
189
  _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
151
190
 
152
191
 
153
- def _unfenced_markdown_lines(content: str):
154
- """Yield (line, start, end) for lines outside Markdown fenced code blocks."""
155
- fence_char = ""
156
- fence_len = 0
157
- offset = 0
158
- for line in content.splitlines(keepends=True):
159
- marker = _MARKDOWN_FENCE_MARKER_RE.match(line)
160
- if fence_char:
161
- if (
162
- marker
163
- and marker.group(1)[0] == fence_char
164
- and len(marker.group(1)) >= fence_len
165
- and not line[marker.end():].strip()
166
- ):
167
- fence_char = ""
168
- fence_len = 0
169
- offset += len(line)
170
- continue
171
-
172
- if marker:
173
- fence_char = marker.group(1)[0]
174
- fence_len = len(marker.group(1))
175
- offset += len(line)
176
- continue
177
-
178
- start = offset
179
- offset += len(line)
180
- yield line, start, offset
181
-
182
-
183
192
  def _plain_table_cell(cell: str) -> str:
184
193
  return cell.strip().strip("_*`").strip()
185
194
 
@@ -218,14 +227,14 @@ def _is_external_docs_inventory_row(line: str) -> bool:
218
227
 
219
228
  def _has_external_docs_inventory(content: str) -> bool:
220
229
  section_start = None
221
- for line, _, end in _unfenced_markdown_lines(content):
230
+ for line, _, end in unfenced_markdown_lines(content):
222
231
  if _EXTERNAL_DOCS_RE.match(line):
223
232
  section_start = end
224
233
  break
225
234
  if section_start is None:
226
235
  return False
227
236
 
228
- for line, start, _ in _unfenced_markdown_lines(content):
237
+ for line, start, _ in unfenced_markdown_lines(content):
229
238
  if start < section_start:
230
239
  continue
231
240
  if _H2_RE.match(line):
@@ -242,15 +251,14 @@ def _has_external_docs_inventory(content: str) -> bool:
242
251
 
243
252
  _PLAN_TASK_RE = re.compile(r"^### PLAN-TASK-\d+", re.MULTILINE)
244
253
  _CODE_FENCE_LINE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(.*)$")
245
- _PLAN_TASK_FIELD_RE = re.compile(
246
- r"^(Spec References|Change Type|TDD Applicable|Files|Skeleton|Steps|Verification):"
247
- )
254
+ _INLINE_CODE_VALUE_RE = re.compile(r"^(`+)(.*?)\1$", re.DOTALL)
255
+ _MARKDOWN_LINK_VALUE_RE = re.compile(r"^\[([^\]]+)\]\([^)]+\)$")
248
256
 
249
257
 
250
258
  def _plan_task_starts(content: str) -> list[int]:
251
259
  return [
252
260
  start
253
- for line, start, _ in _unfenced_markdown_lines(content)
261
+ for line, start, _ in unfenced_markdown_lines(content)
254
262
  if _PLAN_TASK_RE.match(line)
255
263
  ]
256
264
 
@@ -268,7 +276,7 @@ def _plan_task_field_body(task_body: str, field: str) -> str:
268
276
 
269
277
  def _find_plan_task_field(task_body: str, field: str):
270
278
  field_re = re.compile(rf"^{re.escape(field)}:[ \t]*(.*)$")
271
- for line, start, _ in _unfenced_markdown_lines(task_body):
279
+ for line, start, _ in unfenced_markdown_lines(task_body):
272
280
  match = field_re.match(line)
273
281
  if match:
274
282
  return match, start
@@ -276,8 +284,8 @@ def _find_plan_task_field(task_body: str, field: str):
276
284
 
277
285
 
278
286
  def _find_next_plan_task_field_start(task_body: str, after: int) -> int | None:
279
- for line, start, _ in _unfenced_markdown_lines(task_body):
280
- if start >= after and _PLAN_TASK_FIELD_RE.match(line):
287
+ for line, start, _ in unfenced_markdown_lines(task_body):
288
+ if start >= after and PLAN_TASK_FIELD_RE.match(line):
281
289
  return start
282
290
  return None
283
291
 
@@ -290,6 +298,244 @@ def _plan_task_field_value(task_body: str, field: str) -> str:
290
298
  return match.group(1).strip()
291
299
 
292
300
 
301
+ def _iter_plan_task_bodies(content: str):
302
+ return heading_bounded_bodies(content, _PLAN_TASK_RE.match)
303
+
304
+
305
+ def _plan_task_label(body: str) -> str:
306
+ m = re.match(r"###\s+PLAN-TASK-(\d+)", body)
307
+ return f"PLAN-TASK-{m.group(1)}" if m else "PLAN-TASK-?"
308
+
309
+
310
+ def _strip_markdown_path_wrappers(value: str) -> str:
311
+ text = value.strip()
312
+ changed = True
313
+ while changed:
314
+ changed = False
315
+ inline_code = _INLINE_CODE_VALUE_RE.fullmatch(text)
316
+ if inline_code:
317
+ text = inline_code.group(2).strip()
318
+ changed = True
319
+ continue
320
+ markdown_link = _MARKDOWN_LINK_VALUE_RE.fullmatch(text)
321
+ if markdown_link:
322
+ text = markdown_link.group(1).strip()
323
+ changed = True
324
+ continue
325
+ if len(text) >= 2 and text.startswith("<") and text.endswith(">"):
326
+ text = text[1:-1].strip()
327
+ changed = True
328
+ continue
329
+ for marker in ("**", "__"):
330
+ if (
331
+ len(text) > 2 * len(marker)
332
+ and text.startswith(marker)
333
+ and text.endswith(marker)
334
+ ):
335
+ text = text[len(marker):-len(marker)].strip()
336
+ changed = True
337
+ break
338
+ return text
339
+
340
+
341
+ def _plan_task_path_part(raw_path: str) -> str:
342
+ value = _strip_markdown_path_wrappers(raw_path)
343
+ return _strip_markdown_path_wrappers(value.split("::")[0])
344
+
345
+
346
+ _NO_FILE_SENTINELS = frozenset({"n/a"})
347
+
348
+
349
+ def _is_no_file_sentinel(path_part: str) -> bool:
350
+ return path_part.strip().lower() in _NO_FILE_SENTINELS
351
+
352
+
353
+ def _plan_task_file_paths(files_field: str) -> list[str]:
354
+ paths: list[str] = []
355
+ lines = [line for line, _, _ in unfenced_markdown_lines(files_field)]
356
+ if not lines:
357
+ return paths
358
+
359
+ first = lines[0].strip()
360
+ if first:
361
+ raw_path = first[2:].strip() if first.startswith(("- ", "* ")) else first
362
+ path_part = _plan_task_path_part(raw_path)
363
+ if path_part and not _is_no_file_sentinel(path_part):
364
+ paths.append(path_part)
365
+
366
+ for line in lines[1:]:
367
+ stripped = line.strip()
368
+ if not stripped.startswith(("- ", "* ")):
369
+ continue
370
+ path_part = _plan_task_path_part(stripped[2:])
371
+ if path_part and not _is_no_file_sentinel(path_part):
372
+ paths.append(path_part)
373
+ return paths
374
+
375
+
376
+ def _context_pack_repo_root(run_dir: Path) -> Path | None:
377
+ """Usable Context Pack repo_root, or None when the pack is missing,
378
+ unreadable, invalid JSON, lacks repo_root, or does not point at an existing directory."""
379
+ import json
380
+ pack_json = run_dir / "02-project-context.json"
381
+ if not pack_json.exists():
382
+ return None
383
+ try:
384
+ decoded = json.loads(pack_json.read_text(encoding="utf-8"))
385
+ except (ValueError, OSError):
386
+ return None
387
+ if not isinstance(decoded, dict):
388
+ return None
389
+ raw = decoded.get("repo_root", "")
390
+ if not isinstance(raw, str) or not raw.strip():
391
+ return None
392
+ repo_root = Path(raw)
393
+ if not repo_root.is_dir():
394
+ return None
395
+ return repo_root.resolve()
396
+
397
+
398
+ def _python_executable() -> str:
399
+ import shutil
400
+ import sys
401
+ if sys.executable:
402
+ return sys.executable
403
+ return "python3" if shutil.which("python3") else "python"
404
+
405
+
406
+ def _context_pack_remediation_command(run_dir: Path) -> str:
407
+ import shlex
408
+ package_root = Path(__file__).resolve().parents[2]
409
+ pythonpath = f"PYTHONPATH={shlex.quote(str(package_root))}${{PYTHONPATH:+:$PYTHONPATH}}"
410
+ command = (
411
+ f"{pythonpath} {shlex.quote(_python_executable())} -m tools.workflow_cli "
412
+ f"context-build --work-id {run_dir.name}"
413
+ )
414
+ if run_dir.parent.name == ".req-to-plan":
415
+ base_dir = run_dir.parent.parent
416
+ try:
417
+ command += f" --base-path {shlex.quote(str(base_dir.resolve()))}"
418
+ except (OSError, RuntimeError):
419
+ # Path resolution failed (for example, a symlink loop).
420
+ command += " --base-path <base-dir>"
421
+ return command + " --repo-path <repo-dir>"
422
+
423
+
424
+ def _check_plan_context_pack(run_dir: Path) -> list[str]:
425
+ """R11: standard-tier PLAN must anchor file facts to a usable Context Pack.
426
+
427
+ Every no-usable-truth-anchor path blocks loudly; after the user replaces
428
+ <repo-dir>, the remediation command can import the installed workflow modules.
429
+ """
430
+ if _context_pack_repo_root(run_dir) is not None:
431
+ return []
432
+ return [
433
+ "Standard-tier PLAN requires a usable Project Context Pack: "
434
+ "02-project-context.json is missing, unreadable, invalid, or its "
435
+ "repo_root is unavailable. Build it with: "
436
+ f"{_context_pack_remediation_command(run_dir)}"
437
+ ]
438
+
439
+
440
+ def _check_plan_file_refs(run_dir: Path, content: str) -> list[str]:
441
+ """Hard-check Files paths against the Context Pack repo_root. create-type tasks
442
+ are exempt; the part after '::' (a symbol) is advisory and not checked (no AST pack yet)."""
443
+ repo_root = _context_pack_repo_root(run_dir)
444
+ if repo_root is None:
445
+ return [] # no usable ground truth; standard tier blocks via _check_plan_context_pack
446
+ issues: list[str] = []
447
+ for body in _iter_plan_task_bodies(content):
448
+ skip_missing_path = _normalized_change_type(_task_change_type(body)) == "create"
449
+ files_field = _plan_task_field_body(body, "Files")
450
+ for path_part in _plan_task_file_paths(files_field):
451
+ path = Path(path_part)
452
+ if path.is_absolute():
453
+ resolved = path.resolve()
454
+ else:
455
+ resolved = (repo_root / path).resolve()
456
+ try:
457
+ resolved.relative_to(repo_root)
458
+ except ValueError:
459
+ issues.append(
460
+ f"PLAN-TASK Files references path outside repo_root {path_part!r}."
461
+ )
462
+ continue
463
+ if not resolved.exists():
464
+ if not skip_missing_path:
465
+ issues.append(
466
+ f"PLAN-TASK Files references missing path {path_part!r} "
467
+ "(mark the task 'Change Type: create' if it is a new file)."
468
+ )
469
+ return issues
470
+
471
+
472
+ def _check_spec_refs_valid(run_dir: Path, content: str) -> list[str]:
473
+ spec_path = run_dir / STAGE_ARTIFACT_MAP[Stage.SPEC]
474
+ spec_content = (
475
+ strip_readonly_sections(spec_path.read_text(encoding="utf-8"))
476
+ if spec_path.exists()
477
+ else ""
478
+ )
479
+ defined_specs: set[str] = set()
480
+ for line, _, _ in unfenced_markdown_lines(spec_content):
481
+ if line.lstrip().startswith("#"):
482
+ defined_specs.update(re.findall(r"\bSPEC-[A-Z]+-\d+\b", line))
483
+ issues: list[str] = []
484
+ for body in _iter_plan_task_bodies(content):
485
+ refs_body = _plan_task_field_body(body, "Spec References")
486
+ refs = re.findall(r"SPEC-[A-Z]+-\d+", refs_body)
487
+ if refs_body.strip() and not refs:
488
+ issues.append(
489
+ f"{_plan_task_label(body)} must reference at least one SPEC-* ID "
490
+ "in 'Spec References:'."
491
+ )
492
+ for ref in refs:
493
+ if ref not in defined_specs:
494
+ issues.append(f"PLAN-TASK references {ref} which is not defined in the SPEC artifact.")
495
+ return issues
496
+
497
+
498
+ # R10: Change Type is a closed operation-kind enum; 'new' is a legacy alias.
499
+ _CHANGE_TYPE_VALUES = frozenset({"create", "modify", "delete"})
500
+ _CHANGE_TYPE_ALIASES = {"new": "create"}
501
+
502
+
503
+ def _normalized_change_type(raw: str) -> str:
504
+ value = raw.strip().lower()
505
+ return _CHANGE_TYPE_ALIASES.get(value, value)
506
+
507
+
508
+ def _task_change_type(body: str) -> str:
509
+ """Whitespace-normalized Change Type field body (same line + continuation lines)."""
510
+ return " ".join(_plan_task_field_body(body, "Change Type").split())
511
+
512
+
513
+ def _check_plan_task_fields(content: str) -> list[str]:
514
+ issues: list[str] = []
515
+ numbers: list[int] = []
516
+ for body in _iter_plan_task_bodies(content):
517
+ m = re.match(r"###\s+PLAN-TASK-(\d+)", body)
518
+ num = int(m.group(1)) if m else None
519
+ if num is not None:
520
+ numbers.append(num)
521
+ label = _plan_task_label(body)
522
+ for field in PLAN_TASK_FIELDS:
523
+ if not _plan_task_field_body(body, field).strip():
524
+ issues.append(f"{label} is missing a non-empty '{field}:' field.")
525
+ raw_change_type = _task_change_type(body)
526
+ if raw_change_type and _normalized_change_type(raw_change_type) not in _CHANGE_TYPE_VALUES:
527
+ issues.append(
528
+ f"{label} has invalid 'Change Type: {raw_change_type}'; "
529
+ "allowed: create|modify|delete (alias: new = create)."
530
+ )
531
+ if numbers:
532
+ if len(set(numbers)) != len(numbers):
533
+ issues.append("PLAN-TASK numbers must be unique.")
534
+ elif sorted(numbers) != list(range(1, len(numbers) + 1)):
535
+ issues.append("PLAN-TASK numbers must be contiguous starting at 1.")
536
+ return issues
537
+
538
+
293
539
  def _has_complete_code_fence(content: str) -> bool:
294
540
  fence_char = ""
295
541
  fence_len = 0
@@ -324,12 +570,10 @@ def _has_complete_code_fence(content: str) -> bool:
324
570
 
325
571
  def _plan_tasks_missing_code(content: str) -> bool:
326
572
  """True if any TDD-applicable PLAN-TASK has no fenced code block in its Skeleton field."""
327
- starts = _plan_task_starts(content)
328
- if not starts:
573
+ bodies = list(_iter_plan_task_bodies(content))
574
+ if not bodies:
329
575
  return False
330
- bounds = starts + [len(content)]
331
- for i in range(len(starts)):
332
- body = content[bounds[i]:bounds[i + 1]]
576
+ for body in bodies:
333
577
  skeleton = _plan_task_field_body(body, "Skeleton")
334
578
  tdd_applicable = _plan_task_field_value(body, "TDD Applicable")
335
579
  if tdd_applicable.lower() == "yes" and not _has_complete_code_fence(skeleton):
@@ -337,6 +581,312 @@ def _plan_tasks_missing_code(content: str) -> bool:
337
581
  return False
338
582
 
339
583
 
584
+ def _check_plan_task_skeleton_placeholders(content: str) -> list[str]:
585
+ issues: list[str] = []
586
+ for body in _iter_plan_task_bodies(content):
587
+ skeleton = _plan_task_field_body(body, "Skeleton")
588
+ if skeleton.strip() and any(pattern.search(skeleton) for pattern in _PLACEHOLDER_PATTERNS):
589
+ issues.append(
590
+ f"{_plan_task_label(body)} Skeleton contains an unresolved template "
591
+ "placeholder; replace placeholder text before passing the gate."
592
+ )
593
+ return issues
594
+
595
+
596
+ def _section_bodies(content: str, heading: str) -> list[str]:
597
+ """Return all bodies under `heading`, each stopping at the next same-or-higher heading."""
598
+ level = len(heading) - len(heading.lstrip("#"))
599
+ bodies: list[str] = []
600
+ out: list[str] | None = None
601
+ for line, _, _ in unfenced_markdown_lines(content):
602
+ if line.strip() == heading:
603
+ if out is not None:
604
+ bodies.append("\n".join(out))
605
+ out = []
606
+ continue
607
+ if out is not None:
608
+ stripped = line.lstrip()
609
+ if stripped.startswith("#"):
610
+ # Count hashes of this line's heading
611
+ line_level = len(stripped) - len(stripped.lstrip("#"))
612
+ if line_level <= level:
613
+ bodies.append("\n".join(out))
614
+ out = None
615
+ continue
616
+ out.append(line.rstrip("\r\n"))
617
+ if out is not None:
618
+ bodies.append("\n".join(out))
619
+ return bodies
620
+
621
+
622
+ def _section_body(content: str, heading: str) -> str:
623
+ """Return the first section body under `heading`, stopping at the next same-or-higher heading."""
624
+ bodies = _section_bodies(content, heading)
625
+ return bodies[0] if bodies else ""
626
+
627
+
628
+ def _section_entries_missing_id(content: str, heading: str, id_prefix: str) -> list[str]:
629
+ missing: list[str] = []
630
+ pattern = re.compile(rf"\b{re.escape(id_prefix)}-\d+\b")
631
+ for line in _section_body(content, heading).splitlines():
632
+ stripped = line.strip()
633
+ if not stripped or stripped.startswith("<!--"):
634
+ continue
635
+ if not stripped.startswith(("- ", "* ")):
636
+ continue
637
+ if not pattern.search(stripped):
638
+ missing.append(stripped)
639
+ return missing
640
+
641
+
642
+ def _section_entry_ids(content: str, heading: str, id_prefix: str) -> list[str]:
643
+ ids: list[str] = []
644
+ pattern = re.compile(rf"\b{re.escape(id_prefix)}-\d+\b")
645
+ for line in _section_body(content, heading).splitlines():
646
+ stripped = line.strip()
647
+ if not stripped or stripped.startswith("<!--"):
648
+ continue
649
+ if not stripped.startswith(("- ", "* ")):
650
+ continue
651
+ match = pattern.search(stripped)
652
+ if match:
653
+ ids.append(match.group(0))
654
+ return ids
655
+
656
+
657
+ def _section_has_bullets(content: str, heading: str) -> bool:
658
+ return any(l.lstrip().startswith(("- ", "* ")) for l in _section_body(content, heading).splitlines())
659
+
660
+
661
+ def _check_elicitation(stage: Stage, tier: TierEstimate, content: str) -> list[str]:
662
+ """R8: standard-tier brief must record at least one assumption or open question."""
663
+ from tools.workflow_cli.models import TierBase
664
+ if stage != Stage.REQUIREMENT_BRIEF or tier.base != TierBase.STANDARD:
665
+ return []
666
+ if _section_has_bullets(content, "## Assumptions") or _section_has_bullets(content, "## Open Questions"):
667
+ return []
668
+ return ["Standard-tier brief must record at least one assumption or open question (R8 elicitation)."]
669
+
670
+
671
+ # R12: decision-request lifecycle vocabulary. The gate owns ONLY the Status
672
+ # lifecycle (enum, line presence, section non-emptiness, Selected/Rationale
673
+ # when selected); Question/Options/Recommended are template guidance enforced
674
+ # at checkpoint, not here (Agent/CLI boundary).
675
+ _DECISION_SECTION = "## Decision Requests"
676
+ _DECISION_BLOCK_RE = re.compile(r"^###\s+(DECISION-\d+)\b")
677
+ _DECISION_NESTED_MARKER_RE = re.compile(
678
+ r"^(?:#{1,6}\s+|[-*]\s+(?:\[[ xX]\]\s+)?|(?:\[[ xX]\]\s+)?)DECISION-\d+\b"
679
+ )
680
+ _DECISION_STATUS_VALUES = frozenset({"pending", "selected"})
681
+
682
+
683
+ def _decision_field_value(block_lines: list[str], field: str) -> str | None:
684
+ """Value of a `Field:` line within a DECISION block; None when absent."""
685
+ field_re = re.compile(rf"^{re.escape(field)}:\s*(.*)$")
686
+ for line in block_lines:
687
+ m = field_re.match(line.strip())
688
+ if m:
689
+ return m.group(1).strip()
690
+ return None
691
+
692
+
693
+ def _decision_field_count(block_lines: list[str], field: str) -> int:
694
+ field_re = re.compile(rf"^{re.escape(field)}:")
695
+ return sum(1 for line in block_lines if field_re.match(line.strip()))
696
+
697
+
698
+ def _check_decision_requests(stage: Stage, tier: TierEstimate, content: str) -> list[str]:
699
+ """R12: standard DESIGN must list pending human decisions or state `none`."""
700
+ from tools.workflow_cli.models import TierBase
701
+ if stage != Stage.DESIGN or tier.base != TierBase.STANDARD:
702
+ return []
703
+ blocks: list[tuple[str, list[str]]] = []
704
+ stray: list[str] = []
705
+ for section_body in _section_bodies(content, _DECISION_SECTION):
706
+ lines = section_body.splitlines()
707
+ starts = [
708
+ (i, m)
709
+ for i, line in enumerate(lines)
710
+ if (m := _DECISION_BLOCK_RE.match(line.strip()))
711
+ ]
712
+ covered: set[int] = set()
713
+ for start, match in starts:
714
+ end = len(lines)
715
+ for j in range(start + 1, len(lines)):
716
+ if heading_level(lines[j]) is not None:
717
+ end = j
718
+ break
719
+ decision_id = match.group(1)
720
+ blocks.append((decision_id, lines[start + 1:end]))
721
+ covered.update(range(start, end))
722
+ stray.extend(
723
+ line.strip()
724
+ for i, line in enumerate(lines)
725
+ if i not in covered and line.strip() and not line.strip().startswith("<!--")
726
+ )
727
+
728
+ issues: list[str] = []
729
+ if not blocks:
730
+ if stray == ["none"]:
731
+ return []
732
+ if not stray:
733
+ issues.append(
734
+ "## Decision Requests is empty; state exactly `none` or list "
735
+ "`### DECISION-NNN` blocks (R12)."
736
+ )
737
+ else:
738
+ issues.append(
739
+ "## Decision Requests must be exactly `none` (sole non-comment "
740
+ "content) or `### DECISION-NNN` blocks (R12)."
741
+ )
742
+ return issues
743
+ if stray:
744
+ if "none" in stray:
745
+ issues.append(
746
+ "## Decision Requests mixes `none` with DECISION blocks; keep one (R12)."
747
+ )
748
+ else:
749
+ issues.append(
750
+ "## Decision Requests contains non-comment prose outside DECISION blocks; "
751
+ "use exactly `none` or `### DECISION-NNN` blocks (R12)."
752
+ )
753
+ for dup_id, count in Counter(decision_id for decision_id, _ in blocks).items():
754
+ if count > 1:
755
+ issues.append(
756
+ f"Duplicate decision id {dup_id}; each DECISION-NNN must be unique (R12)."
757
+ )
758
+ for decision_id, body in blocks:
759
+ for line in body:
760
+ if _DECISION_NESTED_MARKER_RE.match(line.strip()):
761
+ issues.append(
762
+ f"{decision_id} body contains a nested 'DECISION-NNN' marker; "
763
+ "each decision must be its own '### DECISION-NNN' block (R12)."
764
+ )
765
+ break
766
+ if _decision_field_count(body, "Status") > 1:
767
+ issues.append(
768
+ f"{decision_id} has multiple 'Status:' lines; keep exactly one (R12)."
769
+ )
770
+ continue
771
+ status = _decision_field_value(body, "Status")
772
+ if status is None:
773
+ issues.append(
774
+ f"{decision_id} is missing a 'Status:' line; allowed: pending|selected (R12)."
775
+ )
776
+ continue
777
+ if status.lower() not in _DECISION_STATUS_VALUES:
778
+ issues.append(
779
+ f"{decision_id} has invalid 'Status: {status}'; allowed: pending|selected (R12)."
780
+ )
781
+ continue
782
+ if status.lower() == "pending":
783
+ issues.append(
784
+ f"Unresolved decision request {decision_id} (Status: pending); "
785
+ "a human must choose before this gate can pass (R12)."
786
+ )
787
+ continue
788
+ for field in ("Selected", "Rationale"):
789
+ if not (_decision_field_value(body, field) or "").strip():
790
+ issues.append(
791
+ f"{decision_id} is 'Status: selected' but missing a non-empty "
792
+ f"'{field}:' line (R12)."
793
+ )
794
+ return issues
795
+
796
+
797
+ def _check_scope_freeze(stage: Stage, content: str) -> list[str]:
798
+ """R8: brief's In/Out-of-Scope must carry stable IDs so trace can anchor them."""
799
+ if stage != Stage.REQUIREMENT_BRIEF:
800
+ return []
801
+ issues: list[str] = []
802
+ for entry in _section_entries_missing_id(content, "## In-Scope", "SCOPE-IN"):
803
+ issues.append(f"In-Scope entry must carry a SCOPE-IN-* stable ID (R8): {entry}")
804
+ for entry in _section_entries_missing_id(content, "## Out-of-Scope", "SCOPE-OUT"):
805
+ issues.append(f"Out-of-Scope entry must carry a SCOPE-OUT-* stable ID (R8): {entry}")
806
+ for id_, count in Counter(_section_entry_ids(content, "## In-Scope", "SCOPE-IN")).items():
807
+ if count > 1:
808
+ issues.append(f"In-Scope stable ID {id_} is duplicate; scope IDs must be unique (R8).")
809
+ for id_, count in Counter(_section_entry_ids(content, "## Out-of-Scope", "SCOPE-OUT")).items():
810
+ if count > 1:
811
+ issues.append(f"Out-of-Scope stable ID {id_} is duplicate; scope IDs must be unique (R8).")
812
+ if not re.search(r"\bSCOPE-IN-\d+\b", _section_body(content, "## In-Scope")):
813
+ issues.append("In-Scope must list at least one stable-ID entry (SCOPE-IN-001, ...); none found (R8).")
814
+ if not re.search(r"\bSCOPE-OUT-\d+\b", _section_body(content, "## Out-of-Scope")):
815
+ issues.append("Out-of-Scope must list at least one stable-ID entry (SCOPE-OUT-001, ...); none found (R8).")
816
+ return issues
817
+
818
+
819
+ def _has_meaningful_body(text: str) -> bool:
820
+ """True if `text` has at least one non-empty, non-comment line."""
821
+ for line in text.splitlines():
822
+ stripped = line.strip()
823
+ if stripped and not stripped.startswith("<!--"):
824
+ return True
825
+ return False
826
+
827
+
828
+ def _check_stage_schema(stage: Stage, tier: TierEstimate, content: str) -> list[str]:
829
+ """R2 schema gate: required headings present, each required section has a
830
+ non-placeholder body, trace IDs are well-formed, RISK/DESIGN/SPEC define a
831
+ native ID heading, and no unresolved placeholders remain."""
832
+ from tools.workflow_cli.stage_schema import required_headings
833
+ issues: list[str] = []
834
+ headings = required_headings(stage, tier.base)
835
+ native = _STAGE_NATIVE_HEADING_PATTERNS.get(stage)
836
+ unfenced_content = unfenced_markdown_text(content)
837
+ present_headings = {
838
+ line.strip()
839
+ for line, _, _ in unfenced_markdown_lines(content)
840
+ if line.lstrip().startswith("#")
841
+ }
842
+
843
+ if not headings and native is None:
844
+ return issues
845
+
846
+ # R2.1: required headings must be present
847
+ for heading in headings:
848
+ if heading not in present_headings:
849
+ issues.append(
850
+ f"Missing required section {heading!r} for stage {stage.value!r} "
851
+ f"at tier '{tier.base.value}'."
852
+ )
853
+
854
+ # R2.3a: each required heading's section must have a non-placeholder body
855
+ for heading in headings:
856
+ if heading not in present_headings:
857
+ continue # already reported by the required-heading presence check
858
+ body = _section_body(content, heading)
859
+ if not _has_meaningful_body(body):
860
+ issues.append(
861
+ f"Required section {heading!r} must contain non-placeholder body content."
862
+ )
863
+
864
+ # R2.3b: any trace-ID-looking token must be well-formed
865
+ for token in _TRACE_ID_CANDIDATE_RE.findall(unfenced_content):
866
+ if not _VALID_TRACE_ID_RE.fullmatch(token):
867
+ issues.append(
868
+ f"Malformed trace ID {token!r}; use REQ-AREA-001, SPEC-AREA-001, "
869
+ "SCOPE-IN-001, or PLAN-TASK-001 style IDs."
870
+ )
871
+
872
+ # R2.3c: RISK_DISCOVERY / DESIGN / SPEC must define at least one native trace ID in a heading
873
+ if native is not None and not native.search(unfenced_content):
874
+ issues.append(
875
+ f"Stage {stage.value!r} must define at least one native trace ID in a heading "
876
+ f"matching {native.pattern!r}."
877
+ )
878
+
879
+ # R2.2: placeholder scan
880
+ for pat in _PLACEHOLDER_PATTERNS:
881
+ if pat.search(unfenced_content):
882
+ issues.append(
883
+ "Artifact contains an unresolved placeholder "
884
+ f"(pattern {pat.pattern!r}); fill it before passing the gate."
885
+ )
886
+ break
887
+ return issues
888
+
889
+
340
890
  # ---------------------------------------------------------------------------
341
891
  # Quality Gate
342
892
  # ---------------------------------------------------------------------------
@@ -358,14 +908,22 @@ def check_quality_gate(
358
908
  )
359
909
 
360
910
  issues: list[str] = []
911
+ gate_content = strip_readonly_sections(artifact_content)
361
912
 
362
913
  # Check 2: content must be non-empty
363
- if not artifact_content or not artifact_content.strip():
914
+ if not gate_content or not gate_content.strip():
364
915
  issues.append("Artifact content is empty or whitespace-only.")
365
916
 
366
917
  if not issues:
367
918
  # Check 3: upstream reference coverage closure (all tiers)
368
- unclosed = _find_ids_without_closure(artifact_content)
919
+ unclosed = _find_ids_without_closure(gate_content)
920
+ if stage == Stage.PLAN:
921
+ from tools.workflow_cli.trace import plan_consumed_spec_ids
922
+ consumed_specs = plan_consumed_spec_ids(run_dir)
923
+ unclosed = [
924
+ ref_id for ref_id in unclosed
925
+ if not (ref_id.startswith("SPEC-") and ref_id in consumed_specs)
926
+ ]
369
927
  for ref_id in unclosed:
370
928
  issues.append(
371
929
  f"Upstream reference {ref_id!r} appears in artifact but has no closure status tag "
@@ -373,35 +931,66 @@ def check_quality_gate(
373
931
  )
374
932
 
375
933
  # Check 4: ID uniqueness within artifact
376
- duplicates = _find_duplicate_ids(artifact_content)
934
+ duplicates = _find_duplicate_ids(gate_content)
377
935
  for dup_id in duplicates:
378
936
  issues.append(
379
937
  f"Duplicate ID definition {dup_id!r} found in artifact; each ID must be unique."
380
938
  )
381
939
 
382
- # Check 5 (PLAN, standard tier): TDD-applicable tasks must carry a code block.
940
+ # Check 5 (PLAN, standard tier): usable Context Pack required (R11);
941
+ # TDD-applicable tasks must carry a code block.
383
942
  from tools.workflow_cli.models import TierBase
384
943
  if stage == Stage.PLAN and tier.base == TierBase.STANDARD:
385
- if not _plan_task_starts(artifact_content):
944
+ # R11: a usable Context Pack is the truth anchor for file-ref checks.
945
+ issues.extend(_check_plan_context_pack(run_dir))
946
+ if not _plan_task_starts(gate_content):
386
947
  issues.append(
387
948
  "PLAN is missing '### PLAN-TASK-*' sections; standard tier requires "
388
949
  "machine-parseable executable anchors."
389
950
  )
390
- elif _plan_tasks_missing_code(artifact_content):
951
+ elif _plan_tasks_missing_code(gate_content):
391
952
  issues.append(
392
953
  "PLAN has a 'TDD Applicable: yes' task with no fenced code block; "
393
954
  "add a Skeleton code block (standard tier requires executable anchors)."
394
955
  )
395
956
 
957
+ # Check 5b (PLAN): trace closure — all upstream SPEC/RISK/SCOPE-IN IDs must be consumed.
958
+ if stage == Stage.PLAN:
959
+ from tools.workflow_cli.trace import check_trace_closure, scope_out_violations
960
+ issues.extend(check_trace_closure(run_dir))
961
+ for sid in scope_out_violations(run_dir):
962
+ issues.append(f"PLAN references out-of-scope item {sid}; scope overflow (R8).")
963
+ # R5.1: required fields + contiguous numbering
964
+ issues.extend(_check_plan_task_fields(gate_content))
965
+ # R5.2: dangling SPEC references
966
+ issues.extend(_check_spec_refs_valid(run_dir, gate_content))
967
+ # R5.2b: Skeleton is fenced, so detect template placeholders there explicitly.
968
+ issues.extend(_check_plan_task_skeleton_placeholders(gate_content))
969
+ # R5.3: file refs vs Context Pack repo_root
970
+ issues.extend(_check_plan_file_refs(run_dir, gate_content))
971
+
396
972
  # Check 6 (SPEC): the External Documentation Checked section must be present and non-empty.
397
973
  if stage == Stage.SPEC:
398
- if not _has_external_docs_inventory(artifact_content):
974
+ if not _has_external_docs_inventory(gate_content):
399
975
  issues.append(
400
976
  "SPEC is missing a non-empty '## External Documentation Checked' section. "
401
977
  "Add it; if there are no external dependencies, include an explicit "
402
978
  "'N/A — no external dependencies' row."
403
979
  )
404
980
 
981
+ # Check 7 (R2): tier-aware required-section schema.
982
+ if not issues:
983
+ issues.extend(_check_stage_schema(stage, tier, gate_content))
984
+
985
+ # Check 8 (R8): scope-freeze — In/Out-of-Scope entries must carry stable IDs.
986
+ issues.extend(_check_scope_freeze(stage, gate_content))
987
+
988
+ # Check 9 (R8): elicitation — standard-tier brief must record at least one assumption or open question.
989
+ issues.extend(_check_elicitation(stage, tier, gate_content))
990
+
991
+ # Check 10 (R12): standard DESIGN must resolve decision requests.
992
+ issues.extend(_check_decision_requests(stage, tier, gate_content))
993
+
405
994
  return GateResult(
406
995
  passed=len(issues) == 0,
407
996
  issues=issues,