@xenonbyte/req-2-plan 0.3.0 → 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.
package/README.md CHANGED
@@ -68,12 +68,19 @@ existed beforehand.
68
68
  ### Quick start
69
69
 
70
70
  ```bash
71
- r2p install # install all platforms (default)
72
- r2p-start "Add rate limiting" # start a workflow run
73
- r2p-continue # advance it stage by stage
74
- r2p status # see what is installed
71
+ r2p install # install all platforms (default)
72
+ r2p-start "Add rate limiting" --repo-path . # start a run grounded in this repo's facts
73
+ r2p-continue # advance it stage by stage
74
+ r2p status # see what is installed
75
75
  ```
76
76
 
77
+ Pass `--repo-path .` whenever the requirement targets the current project (use the
78
+ target repo's path for cross-repo work); it generates the Project Context Pack that
79
+ grounds tier estimation and PLAN file-reference checks. If a standard-tier PLAN gate
80
+ later reports a missing or unusable Context Pack, build it mid-run with
81
+ the `PYTHONPATH=... <python> -m tools.workflow_cli context-build ...` command printed
82
+ by the gate (there is no standalone `context-build` executable).
83
+
77
84
  ### Lifecycle commands
78
85
 
79
86
  Install all platforms, one platform, or a comma-separated list:
package/README.zh-CN.md CHANGED
@@ -62,12 +62,18 @@ r2p help
62
62
  ### Quick start
63
63
 
64
64
  ```bash
65
- r2p install # 安装全部平台(默认)
66
- r2p-start "Add rate limiting" # 启动一次工作流
67
- r2p-continue # 逐阶段推进
68
- r2p status # 查看已安装情况
65
+ r2p install # 安装全部平台(默认)
66
+ r2p-start "Add rate limiting" --repo-path . # 启动一次以当前仓库事实为锚点的工作流
67
+ r2p-continue # 逐阶段推进
68
+ r2p status # 查看已安装情况
69
69
  ```
70
70
 
71
+ 需求针对当前项目时必传 `--repo-path .`(跨仓库需求传目标仓库路径);它生成的
72
+ Project Context Pack 是 tier 估算与 PLAN 文件引用校验的真值锚点。若 standard tier
73
+ 的 PLAN gate 提示 Context Pack 缺失/不可用,直接执行 gate 打印的
74
+ `PYTHONPATH=... <python> -m tools.workflow_cli context-build ...` 命令中途补建
75
+ (不存在独立的 `context-build` 可执行文件)。
76
+
71
77
  ### Lifecycle commands
72
78
 
73
79
  安装全部平台、单个平台,或逗号分隔的列表:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenonbyte/req-2-plan",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Requirement-to-PLAN workflow CLI and agent integration installer.",
5
5
  "bin": {
6
6
  "r2p": "bin/r2p.js"
@@ -13,16 +13,18 @@ Run each via Bash using the scripts in `{{R2P_BIN_DIR}}`:
13
13
 
14
14
  | Command | Purpose |
15
15
  |---|---|
16
- | `{{R2P_BIN_DIR}}/r2p-start [--separate] ("<requirement>" \| --file <path>)` | Start a new workflow run |
16
+ | `{{R2P_BIN_DIR}}/r2p-start [--separate] [--repo-path <dir>] ("<requirement>" \| --file <path>)` | Start a new workflow run; `--repo-path` grounds tier estimation and the Context Pack in real repo facts |
17
17
  | `{{R2P_BIN_DIR}}/r2p-continue` | Continue the active run |
18
18
  | `{{R2P_BIN_DIR}}/r2p-tier-lock --work-id <id> --base <light\|standard> --confirm` | Lock the tier for a run |
19
19
  | `{{R2P_BIN_DIR}}/r2p-status [--all]` | Inspect run state (read-only) |
20
20
  | `{{R2P_BIN_DIR}}/r2p-switch --work-id <id>` | Switch active run pointer |
21
21
  | `{{R2P_BIN_DIR}}/r2p-reopen --from <work-id> --stage <stage> --reason "<text>"` | Reopen a closed run |
22
+ | `{{R2P_BIN_DIR}}/r2p-gap-open --work-id <id> --owner-stage <stage> --required-action "<text>"` | Route an upstream gap back to its owner stage |
23
+ | `{{R2P_BIN_DIR}}/r2p-gap-resolve --work-id <id> --route-id <route-id>` | Resolve an open upstream-gap route after the owner stage re-passes gate-quality |
22
24
 
23
25
  ## Usage Pattern
24
26
 
25
- 1. `r2p-start ("<requirement>" | --file <path>)` — start a new run
27
+ 1. `r2p-start [--repo-path <dir>] ("<requirement>" | --file <path>)` — start a new run; pass `--repo-path` when the requirement targets an existing repo
26
28
  2. `r2p-continue` — repeatedly; runs safe automatic steps, stops and suggests the next command when a human action is needed
27
29
  - Stops at: tier not locked, needs stage artifact content, needs stage ready mark, needs human checkpoint approval, entry gate failed, needs repair (Quality Gate failed or changes requested)
28
30
  - When stopped at `needs_content` or `needs_repair`, write the required artifact content into the printed `content_file`, then run the printed `next:` command exactly
@@ -22,6 +22,7 @@ from tools.workflow_cli.models import (
22
22
  )
23
23
  from tools.workflow_cli.markdown import (
24
24
  heading_bounded_bodies,
25
+ heading_level,
25
26
  strip_readonly_sections,
26
27
  unfenced_markdown_lines,
27
28
  unfenced_markdown_text,
@@ -342,6 +343,13 @@ def _plan_task_path_part(raw_path: str) -> str:
342
343
  return _strip_markdown_path_wrappers(value.split("::")[0])
343
344
 
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
+
345
353
  def _plan_task_file_paths(files_field: str) -> list[str]:
346
354
  paths: list[str] = []
347
355
  lines = [line for line, _, _ in unfenced_markdown_lines(files_field)]
@@ -352,7 +360,7 @@ def _plan_task_file_paths(files_field: str) -> list[str]:
352
360
  if first:
353
361
  raw_path = first[2:].strip() if first.startswith(("- ", "* ")) else first
354
362
  path_part = _plan_task_path_part(raw_path)
355
- if path_part:
363
+ if path_part and not _is_no_file_sentinel(path_part):
356
364
  paths.append(path_part)
357
365
 
358
366
  for line in lines[1:]:
@@ -360,29 +368,84 @@ def _plan_task_file_paths(files_field: str) -> list[str]:
360
368
  if not stripped.startswith(("- ", "* ")):
361
369
  continue
362
370
  path_part = _plan_task_path_part(stripped[2:])
363
- if path_part:
371
+ if path_part and not _is_no_file_sentinel(path_part):
364
372
  paths.append(path_part)
365
373
  return paths
366
374
 
367
375
 
368
- def _check_plan_file_refs(run_dir: Path, content: str) -> list[str]:
369
- """Hard-check Files paths against the Context Pack repo_root. create-type tasks
370
- are exempt; the part after '::' (a symbol) is advisory and not checked (no AST pack yet)."""
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."""
371
379
  import json
372
380
  pack_json = run_dir / "02-project-context.json"
373
381
  if not pack_json.exists():
374
- return [] # no ground truth -> advisory only
382
+ return None
375
383
  try:
376
- repo_root = Path(json.loads(pack_json.read_text(encoding="utf-8")).get("repo_root", ""))
384
+ decoded = json.loads(pack_json.read_text(encoding="utf-8"))
377
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:
378
431
  return []
379
- if not repo_root or not repo_root.exists():
380
- return []
381
- repo_root = repo_root.resolve()
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
382
446
  issues: list[str] = []
383
447
  for body in _iter_plan_task_bodies(content):
384
- change_type = _plan_task_field_value(body, "Change Type").strip().lower()
385
- skip_missing_path = change_type == "create"
448
+ skip_missing_path = _normalized_change_type(_task_change_type(body)) == "create"
386
449
  files_field = _plan_task_field_body(body, "Files")
387
450
  for path_part in _plan_task_file_paths(files_field):
388
451
  path = Path(path_part)
@@ -432,6 +495,21 @@ def _check_spec_refs_valid(run_dir: Path, content: str) -> list[str]:
432
495
  return issues
433
496
 
434
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
+
435
513
  def _check_plan_task_fields(content: str) -> list[str]:
436
514
  issues: list[str] = []
437
515
  numbers: list[int] = []
@@ -444,6 +522,12 @@ def _check_plan_task_fields(content: str) -> list[str]:
444
522
  for field in PLAN_TASK_FIELDS:
445
523
  if not _plan_task_field_body(body, field).strip():
446
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
+ )
447
531
  if numbers:
448
532
  if len(set(numbers)) != len(numbers):
449
533
  issues.append("PLAN-TASK numbers must be unique.")
@@ -509,23 +593,36 @@ def _check_plan_task_skeleton_placeholders(content: str) -> list[str]:
509
593
  return issues
510
594
 
511
595
 
512
- def _section_body(content: str, heading: str) -> str:
513
- """Return the text of the section under `heading`, stopping at the next same-or-higher heading."""
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."""
514
598
  level = len(heading) - len(heading.lstrip("#"))
515
- out, capture = [], False
599
+ bodies: list[str] = []
600
+ out: list[str] | None = None
516
601
  for line, _, _ in unfenced_markdown_lines(content):
517
602
  if line.strip() == heading:
518
- capture = True
603
+ if out is not None:
604
+ bodies.append("\n".join(out))
605
+ out = []
519
606
  continue
520
- if capture:
607
+ if out is not None:
521
608
  stripped = line.lstrip()
522
609
  if stripped.startswith("#"):
523
610
  # Count hashes of this line's heading
524
611
  line_level = len(stripped) - len(stripped.lstrip("#"))
525
612
  if line_level <= level:
526
- break
613
+ bodies.append("\n".join(out))
614
+ out = None
615
+ continue
527
616
  out.append(line.rstrip("\r\n"))
528
- return "\n".join(out)
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 ""
529
626
 
530
627
 
531
628
  def _section_entries_missing_id(content: str, heading: str, id_prefix: str) -> list[str]:
@@ -571,6 +668,132 @@ def _check_elicitation(stage: Stage, tier: TierEstimate, content: str) -> list[s
571
668
  return ["Standard-tier brief must record at least one assumption or open question (R8 elicitation)."]
572
669
 
573
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
+
574
797
  def _check_scope_freeze(stage: Stage, content: str) -> list[str]:
575
798
  """R8: brief's In/Out-of-Scope must carry stable IDs so trace can anchor them."""
576
799
  if stage != Stage.REQUIREMENT_BRIEF:
@@ -714,9 +937,12 @@ def check_quality_gate(
714
937
  f"Duplicate ID definition {dup_id!r} found in artifact; each ID must be unique."
715
938
  )
716
939
 
717
- # 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.
718
942
  from tools.workflow_cli.models import TierBase
719
943
  if stage == Stage.PLAN and tier.base == TierBase.STANDARD:
944
+ # R11: a usable Context Pack is the truth anchor for file-ref checks.
945
+ issues.extend(_check_plan_context_pack(run_dir))
720
946
  if not _plan_task_starts(gate_content):
721
947
  issues.append(
722
948
  "PLAN is missing '### PLAN-TASK-*' sections; standard tier requires "
@@ -762,6 +988,9 @@ def check_quality_gate(
762
988
  # Check 9 (R8): elicitation — standard-tier brief must record at least one assumption or open question.
763
989
  issues.extend(_check_elicitation(stage, tier, gate_content))
764
990
 
991
+ # Check 10 (R12): standard DESIGN must resolve decision requests.
992
+ issues.extend(_check_decision_requests(stage, tier, gate_content))
993
+
765
994
  return GateResult(
766
995
  passed=len(issues) == 0,
767
996
  issues=issues,
@@ -33,8 +33,8 @@ STAGE_SCHEMA: dict = {
33
33
  TierBase.LIGHT: ["## Design Summary", "## Chosen Design", "## SPEC Handoff"],
34
34
  TierBase.STANDARD: [
35
35
  "## Design Summary", "## Current Code Evidence", "## Requirements Coverage",
36
- "## Options Considered", "## Chosen Design", "## Rollback",
37
- "## Observability", "## SPEC Handoff",
36
+ "## Options Considered", "## Chosen Design", "## Decision Requests",
37
+ "## Rollback", "## Observability", "## SPEC Handoff",
38
38
  ],
39
39
  },
40
40
  Stage.SPEC: {
@@ -22,6 +22,17 @@ _HEADING_BODY = {
22
22
  (Stage.REQUIREMENT_BRIEF, "## Out-of-Scope"): "- SCOPE-OUT-001 <!-- fill in -->\n",
23
23
  (Stage.RISK_DISCOVERY, "## Risks"): "### RISK-SEC-001 <!-- fill in -->\nStatus: <!-- fill in -->\n",
24
24
  (Stage.DESIGN, "## Chosen Design"): "### DES-ARCH-001 <!-- fill in -->\n",
25
+ (Stage.DESIGN, "## Decision Requests"): (
26
+ "<!-- fill in -->\n"
27
+ "<!-- Write exactly `none` when no human decision is needed; otherwise list one `### DECISION-NNN` block per choice (fenced example below; keep guidance comments single-line). -->\n"
28
+ "```text\n"
29
+ "### DECISION-001 <short title>\n"
30
+ "Question: <what must a human choose?>\n"
31
+ "Options: A) ... / B) ...\n"
32
+ "Recommended: A\n"
33
+ "Status: pending\n"
34
+ "```\n"
35
+ ),
25
36
  (Stage.SPEC, "## Behavior Contracts"): "### SPEC-BEHAVIOR-001 <!-- fill in -->\n",
26
37
  (Stage.PLAN, "## Tasks"): (
27
38
  "### PLAN-TASK-001 <!-- fill in -->\n"
@@ -200,17 +200,72 @@ def spec_ids_not_consumed(run_dir: Path) -> list[str]:
200
200
  if id_.startswith("SPEC-") and id_ not in consumed)
201
201
 
202
202
 
203
+ _NON_GOALS_TITLE = "non-goals"
204
+
205
+
206
+ def _strip_nested_non_goals(block: str) -> str:
207
+ """Remove Non-goals subsections nested inside a SPEC block (R9).
208
+
209
+ Only headings deeper than the block's own heading qualify; an exempt
210
+ subsection runs to the next same-or-higher heading, so a later sibling
211
+ section still counts toward scope-overflow scanning. The document-level
212
+ `## Non-goals` never enters a SPEC block (see `_heading_blocks`), so it
213
+ needs no handling here.
214
+ """
215
+ headings: list[tuple[int, int, bool]] = [] # (offset, level, is_non_goals)
216
+ block_level: int | None = None
217
+ offset = 0
218
+ for line in block.splitlines(keepends=True):
219
+ level = heading_level(line)
220
+ if level is not None:
221
+ if block_level is None:
222
+ block_level = level # the SPEC block's own heading
223
+ else:
224
+ title = line.strip().strip("#").strip().lower()
225
+ headings.append((offset, level, title == _NON_GOALS_TITLE))
226
+ offset += len(line)
227
+ removals: list[tuple[int, int]] = []
228
+ for i, (start, level, is_non_goals) in enumerate(headings):
229
+ if not is_non_goals or (block_level is not None and level <= block_level):
230
+ continue
231
+ end = len(block)
232
+ for next_start, next_level, _ in headings[i + 1:]:
233
+ if next_level <= level:
234
+ end = next_start
235
+ break
236
+ removals.append((start, end))
237
+ pieces: list[str] = []
238
+ cursor = 0
239
+ for start, end in removals:
240
+ if start < cursor:
241
+ continue # nested inside an already-removed Non-goals section
242
+ pieces.append(block[cursor:start])
243
+ cursor = end
244
+ pieces.append(block[cursor:])
245
+ return "".join(pieces)
246
+
247
+
203
248
  def scope_out_violations(run_dir: Path) -> list[str]:
204
- """SCOPE-OUT-* ids that executable PLAN-TASK bodies reference a scope overflow (R8)."""
249
+ """SCOPE-OUT-* ids that PLAN-TASK bodies reference, directly or via a
250
+ consumed SPEC block — a scope overflow (R8/R9). Non-goals subsections
251
+ nested inside a consumed SPEC block are exempt (legitimate exclusion
252
+ declarations)."""
205
253
  plan_text = _artifact_text(run_dir, Stage.PLAN)
206
254
  plan_task_text = "\n".join(unfenced_markdown_text(body) for body in _plan_task_bodies(plan_text))
207
- return sorted(
208
- {
255
+ violations = {
256
+ m.group(0)
257
+ for m in _ID_RE.finditer(plan_task_text)
258
+ if m.group(0).startswith("SCOPE-OUT-")
259
+ }
260
+ spec_blocks = _spec_blocks(_artifact_text(run_dir, Stage.SPEC))
261
+ for spec_id in plan_consumed_spec_ids(run_dir):
262
+ scanned = _strip_nested_non_goals(spec_blocks.get(spec_id, ""))
263
+ violations.update(
209
264
  m.group(0)
210
- for m in _ID_RE.finditer(plan_task_text)
265
+ for m in _ID_RE.finditer(scanned)
211
266
  if m.group(0).startswith("SCOPE-OUT-")
212
- }
213
- )
267
+ )
268
+ return sorted(violations)
214
269
 
215
270
 
216
271
  def check_trace_closure(run_dir: Path) -> list[str]:
@@ -1 +1 @@
1
- R2P_VERSION = "0.3.0"
1
+ R2P_VERSION = "0.4.0"