@xenonbyte/req-2-plan 0.3.0 → 0.4.1

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
@@ -67,13 +67,34 @@ existed beforehand.
67
67
 
68
68
  ### Quick start
69
69
 
70
+ Install the platform skills, then check what landed (terminal, lifecycle CLI):
71
+
70
72
  ```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
73
+ r2p install # install all platforms (default)
74
+ r2p status # see what is installed
75
+ ```
76
+
77
+ Then drive the workflow from your agent — the installed skills call the `r2p-*`
78
+ wrappers (to run them in a terminal instead, add `~/.req-to-plan/bin` to `PATH`,
79
+ see the tip below):
80
+
81
+ ```text
82
+ /r2p-start --repo-path . "Add rate limiting" # requirement as inline text
83
+ /r2p-start --repo-path . --file change-req.md # requirement as a document
84
+ /r2p-continue # advance it stage by stage
75
85
  ```
76
86
 
87
+ A requirement can be inline text or a document passed with `--file <path>`
88
+ (the two are mutually exclusive). **Whenever a code repository is the
89
+ requirement's context, pass `--repo-path`** — `.` for the current project,
90
+ the target repo's path for cross-repo work; it generates the Project Context
91
+ Pack that grounds tier estimation and PLAN file-reference checks. Keep options
92
+ before the requirement text (as above) so a quoting slip in free-form text can
93
+ never swallow an option. If a standard-tier PLAN gate later reports a missing
94
+ or unusable Context Pack, build it mid-run with
95
+ the `PYTHONPATH=... <python> -m tools.workflow_cli context-build ...` command printed
96
+ by the gate (there is no standalone `context-build` executable).
97
+
77
98
  ### Lifecycle commands
78
99
 
79
100
  Install all platforms, one platform, or a comma-separated list:
@@ -167,6 +188,16 @@ r2p-gap-resolve --work-id <id> --route-id R-1
167
188
  > walks you through both repair flows with `needs_repair` and `needs_gap_resolve`
168
189
  > stops.
169
190
 
191
+ > [!NOTE]
192
+ > **Human decision points (standard DESIGN).** When a standard-tier DESIGN
193
+ > involves a choice a human must make (new dependency, migration strategy,
194
+ > API compatibility), the agent records it in the `## Decision Requests`
195
+ > section as a `### DECISION-NNN` block with `Question:`, `Options:`,
196
+ > `Recommended:`, and `Status: pending` — and a pending decision fails
197
+ > `gate-quality` until a human chooses and the block becomes
198
+ > `Status: selected` with `Selected:` and `Rationale:` lines.
199
+ > Write exactly `none` in that section when no human decision is needed.
200
+
170
201
  ## License
171
202
 
172
203
  [MIT](./LICENSE) © xenonbyte
package/README.zh-CN.md CHANGED
@@ -61,13 +61,31 @@ r2p help
61
61
 
62
62
  ### Quick start
63
63
 
64
+ 先在终端用生命周期 CLI 安装平台技能并确认安装结果:
65
+
64
66
  ```bash
65
- r2p install # 安装全部平台(默认)
66
- r2p-start "Add rate limiting" # 启动一次工作流
67
- r2p-continue # 逐阶段推进
68
- r2p status # 查看已安装情况
67
+ r2p install # 安装全部平台(默认)
68
+ r2p status # 查看已安装情况
69
+ ```
70
+
71
+ 然后在 agent 里驱动工作流——已安装的平台技能会调用 `r2p-*` 包装器
72
+ (如需在终端手动执行,先把 `~/.req-to-plan/bin` 加入 `PATH`,见下方 tip):
73
+
74
+ ```text
75
+ /r2p-start --repo-path . "Add rate limiting" # 需求为内联文本
76
+ /r2p-start --repo-path . --file change-req.md # 需求为文档文件
77
+ /r2p-continue # 逐阶段推进
69
78
  ```
70
79
 
80
+ 需求可以是内联文本,也可以用 `--file <path>` 传入文档(两者互斥)。
81
+ **只要需求以某个代码仓库为上下文,就必须传 `--repo-path`**——当前项目传 `.`,
82
+ 跨仓库需求传目标仓库路径;它生成的 Project Context Pack 是 tier 估算与 PLAN
83
+ 文件引用校验的真值锚点。选项写在需求文本之前(如上例),这样即使自由文本
84
+ 引号写错也不会吞掉选项。若 standard tier 的 PLAN gate 提示 Context Pack
85
+ 缺失/不可用,直接执行 gate 打印的
86
+ `PYTHONPATH=... <python> -m tools.workflow_cli context-build ...` 命令中途补建
87
+ (不存在独立的 `context-build` 可执行文件)。
88
+
71
89
  ### Lifecycle commands
72
90
 
73
91
  安装全部平台、单个平台,或逗号分隔的列表:
@@ -153,6 +171,15 @@ r2p-gap-resolve --work-id <id> --route-id R-1
153
171
  > reopen 针对**已关闭**的 run;gap 路由针对**开着**的 run。`r2p-continue` 会用
154
172
  > `needs_repair` 和 `needs_gap_resolve` 停点带你走完这两种修复流程。
155
173
 
174
+ > [!NOTE]
175
+ > **人工决策点(standard DESIGN)。** 当 standard tier 的 DESIGN 涉及必须由人
176
+ > 决定的选择(引入新依赖、迁移策略、API 兼容性)时,agent 会在 `## Decision
177
+ > Requests` 章节写入 `### DECISION-NNN` block(含 `Question:`/`Options:`/
178
+ > `Recommended:`)并标记 `Status: pending` ——存在 pending 决策时
179
+ > `gate-quality` 会失败,直到人选定方案、block 改为 `Status: selected`
180
+ > 并补上 `Selected:` 与 `Rationale:` 行。
181
+ > 无需人工决策时,该章节须恰好写 `none`。
182
+
156
183
  ## License
157
184
 
158
185
  [MIT](./LICENSE) © xenonbyte
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.1",
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
@@ -31,4 +33,5 @@ Run each via Bash using the scripts in `{{R2P_BIN_DIR}}`:
31
33
  - Auto-advances: moves to the next stage after non-PLAN checkpoint approval, then runs that stage's entry gate
32
34
  - Auto-closes: closes the run when the PLAN checkpoint is approved and no open routes remain
33
35
  - Does NOT auto-mark artifacts ready and does NOT auto-approve checkpoints — those are human steps
36
+ - Standard-tier DESIGN: record any human technical choice in `## Decision Requests` as a `### DECISION-NNN` block (`Question:`/`Options:`/`Recommended:`/`Status: pending`); pending blocks `gate-quality` until a human selects (`Status: selected` + `Selected:`/`Rationale:`), or write exactly `none` when no decision is needed
34
37
  3. When the run closes, hand the approved PLAN at `07-plan.md` directly to your executor — the PLAN is executor-neutral and needs no adaptation step.
@@ -790,23 +790,22 @@ def _cmd_tier_escalate(args):
790
790
  previous_tier = record.tier_locked
791
791
  record.tier_locked = record.tier_locked.escalate(modifier)
792
792
 
793
- plan_gate_passed_statuses = {
793
+ gate_passed_statuses = {
794
794
  RunStatus.READY_FOR_CHECKPOINT_REVIEW,
795
795
  RunStatus.CHECKPOINT_REVIEW,
796
796
  }
797
- standard_plan_gate_became_applicable = (
798
- record.current_stage == Stage.PLAN
799
- and previous_tier.base != TierBase.STANDARD
797
+ standard_gate_became_applicable = (
798
+ previous_tier.base != TierBase.STANDARD
800
799
  and record.tier_locked.base == TierBase.STANDARD
801
- and record.status in plan_gate_passed_statuses
800
+ and record.status in gate_passed_statuses
802
801
  )
803
- if standard_plan_gate_became_applicable:
802
+ if standard_gate_became_applicable:
804
803
  record = update_run_status(record, RunStatus.ACTIVE_STAGE_DRAFT)
805
804
  update_resume_context(
806
805
  record,
807
806
  last_operation=f"tier_escalated_{modifier.value}",
808
807
  next_operation="gate_quality",
809
- active_item=Stage.PLAN.value,
808
+ active_item=record.current_stage.value,
810
809
  )
811
810
 
812
811
  # Revoke affected bundle authorizations that cover high-tier stages
@@ -822,14 +821,26 @@ def _cmd_tier_escalate(args):
822
821
  ba.revoked_at = revoke_ts
823
822
 
824
823
  mgr.save(record)
824
+ data = {
825
+ "work_id": str(record.work_id),
826
+ "tier_base": record.tier_locked.base.value,
827
+ "modifiers": sorted(m.value for m in record.tier_locked.modifiers),
828
+ "added_modifier": modifier.value,
829
+ }
830
+ if (
831
+ previous_tier.base != TierBase.STANDARD
832
+ and record.tier_locked.base == TierBase.STANDARD
833
+ and STAGE_ORDER.index(record.current_stage) > STAGE_ORDER.index(Stage.DESIGN)
834
+ ):
835
+ data["note"] = (
836
+ "DESIGN was approved under light tier; if this escalation "
837
+ "changes design decisions, run r2p-gap-open --work-id "
838
+ f"{record.work_id} --owner-stage design "
839
+ '--required-action "<describe the design impact>"'
840
+ )
825
841
  print_and_exit(
826
842
  format_success(
827
- {
828
- "work_id": str(record.work_id),
829
- "tier_base": record.tier_locked.base.value,
830
- "modifiers": sorted(m.value for m in record.tier_locked.modifiers),
831
- "added_modifier": modifier.value,
832
- },
843
+ data,
833
844
  message=f"Tier escalated with modifier: {modifier.value}",
834
845
  ),
835
846
  EXIT_OK,
@@ -22,7 +22,7 @@ class ProjectContextPack:
22
22
  package_managers: list = field(default_factory=list)
23
23
  test_commands: list = field(default_factory=list)
24
24
  entrypoints: list = field(default_factory=list)
25
- dependencies: list = field(default_factory=list) # [{name, version, ecosystem}]
25
+ dependencies: list = field(default_factory=list) # [{name, version, ecosystem, dev?}]
26
26
  config_files: list = field(default_factory=list)
27
27
  source_dirs: list = field(default_factory=list)
28
28
 
@@ -35,13 +35,21 @@ def build_context_pack(repo_path: Path) -> ProjectContextPack:
35
35
  pkg = repo_path / "package.json"
36
36
  if pkg.exists():
37
37
  try:
38
- data = json.loads(pkg.read_text(encoding="utf-8"))
38
+ raw_data = json.loads(pkg.read_text(encoding="utf-8"))
39
39
  pack.package_managers.append("npm")
40
- test = (data.get("scripts") or {}).get("test")
41
- if test:
42
- pack.test_commands.append(test)
43
- for name, ver in (data.get("dependencies") or {}).items():
44
- pack.dependencies.append({"name": name, "version": ver, "ecosystem": "npm"})
40
+ data = raw_data if isinstance(raw_data, dict) else {}
41
+ scripts = data.get("scripts")
42
+ if isinstance(scripts, dict) and scripts.get("test"):
43
+ pack.test_commands.append("npm test")
44
+ dependencies = data.get("dependencies")
45
+ if isinstance(dependencies, dict):
46
+ for name, ver in dependencies.items():
47
+ pack.dependencies.append({"name": name, "version": ver, "ecosystem": "npm"})
48
+ dev_dependencies = data.get("devDependencies")
49
+ if isinstance(dev_dependencies, dict):
50
+ for name, ver in dev_dependencies.items():
51
+ pack.dependencies.append(
52
+ {"name": name, "version": ver, "ecosystem": "npm", "dev": True})
45
53
  except (ValueError, OSError):
46
54
  pass
47
55
 
@@ -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"
@@ -146,7 +146,9 @@ def _risk_blocks(content: str) -> dict[str, str]:
146
146
 
147
147
 
148
148
  def scope_in_not_closed(run_dir: Path) -> list[str]:
149
- """SCOPE-IN closes only when a PLAN-TASK carries it or consumes a SPEC carrying it."""
149
+ """SCOPE-IN closes only when a PLAN-TASK carries it or consumes a SPEC
150
+ carrying it outside the SPEC block's nested Non-goals subsections (R14:
151
+ 'explicitly not implemented here' must not close the scope item)."""
150
152
  model = build_trace(run_dir)
151
153
  plan_text = _artifact_text(run_dir, Stage.PLAN)
152
154
  plan_task_text = "\n".join(unfenced_markdown_text(body) for body in _plan_task_bodies(plan_text))
@@ -156,7 +158,8 @@ def scope_in_not_closed(run_dir: Path) -> list[str]:
156
158
  for id_ in sorted(i for i in model.defined if i.startswith("SCOPE-IN-")):
157
159
  if id_ in plan_task_text:
158
160
  continue
159
- if any(id_ in spec_blocks.get(spec_id, "") for spec_id in consumed_specs):
161
+ if any(id_ in _strip_nested_non_goals(spec_blocks.get(spec_id, ""))
162
+ for spec_id in consumed_specs):
160
163
  continue
161
164
  issues.append(id_)
162
165
  return issues
@@ -200,17 +203,72 @@ def spec_ids_not_consumed(run_dir: Path) -> list[str]:
200
203
  if id_.startswith("SPEC-") and id_ not in consumed)
201
204
 
202
205
 
206
+ _NON_GOALS_TITLE = "non-goals"
207
+
208
+
209
+ def _strip_nested_non_goals(block: str) -> str:
210
+ """Remove Non-goals subsections nested inside a SPEC block (R9).
211
+
212
+ Only headings deeper than the block's own heading qualify; an exempt
213
+ subsection runs to the next same-or-higher heading, so a later sibling
214
+ section still counts toward scope-overflow scanning. The document-level
215
+ `## Non-goals` never enters a SPEC block (see `_heading_blocks`), so it
216
+ needs no handling here.
217
+ """
218
+ headings: list[tuple[int, int, bool]] = [] # (offset, level, is_non_goals)
219
+ block_level: int | None = None
220
+ offset = 0
221
+ for line in block.splitlines(keepends=True):
222
+ level = heading_level(line)
223
+ if level is not None:
224
+ if block_level is None:
225
+ block_level = level # the SPEC block's own heading
226
+ else:
227
+ title = line.strip().strip("#").strip().lower()
228
+ headings.append((offset, level, title == _NON_GOALS_TITLE))
229
+ offset += len(line)
230
+ removals: list[tuple[int, int]] = []
231
+ for i, (start, level, is_non_goals) in enumerate(headings):
232
+ if not is_non_goals or (block_level is not None and level <= block_level):
233
+ continue
234
+ end = len(block)
235
+ for next_start, next_level, _ in headings[i + 1:]:
236
+ if next_level <= level:
237
+ end = next_start
238
+ break
239
+ removals.append((start, end))
240
+ pieces: list[str] = []
241
+ cursor = 0
242
+ for start, end in removals:
243
+ if start < cursor:
244
+ continue # nested inside an already-removed Non-goals section
245
+ pieces.append(block[cursor:start])
246
+ cursor = end
247
+ pieces.append(block[cursor:])
248
+ return "".join(pieces)
249
+
250
+
203
251
  def scope_out_violations(run_dir: Path) -> list[str]:
204
- """SCOPE-OUT-* ids that executable PLAN-TASK bodies reference a scope overflow (R8)."""
252
+ """SCOPE-OUT-* ids that PLAN-TASK bodies reference, directly or via a
253
+ consumed SPEC block — a scope overflow (R8/R9). Non-goals subsections
254
+ nested inside a consumed SPEC block are exempt (legitimate exclusion
255
+ declarations)."""
205
256
  plan_text = _artifact_text(run_dir, Stage.PLAN)
206
257
  plan_task_text = "\n".join(unfenced_markdown_text(body) for body in _plan_task_bodies(plan_text))
207
- return sorted(
208
- {
258
+ violations = {
259
+ m.group(0)
260
+ for m in _ID_RE.finditer(plan_task_text)
261
+ if m.group(0).startswith("SCOPE-OUT-")
262
+ }
263
+ spec_blocks = _spec_blocks(_artifact_text(run_dir, Stage.SPEC))
264
+ for spec_id in plan_consumed_spec_ids(run_dir):
265
+ scanned = _strip_nested_non_goals(spec_blocks.get(spec_id, ""))
266
+ violations.update(
209
267
  m.group(0)
210
- for m in _ID_RE.finditer(plan_task_text)
268
+ for m in _ID_RE.finditer(scanned)
211
269
  if m.group(0).startswith("SCOPE-OUT-")
212
- }
213
- )
270
+ )
271
+ return sorted(violations)
214
272
 
215
273
 
216
274
  def check_trace_closure(run_dir: Path) -> list[str]:
@@ -1 +1 @@
1
- R2P_VERSION = "0.3.0"
1
+ R2P_VERSION = "0.4.1"