@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 +11 -4
- package/README.zh-CN.md +10 -4
- package/package.json +1 -1
- package/tools/workflow_cli/agent_templates/claude/SKILL.md +4 -2
- package/tools/workflow_cli/gates.py +249 -20
- package/tools/workflow_cli/stage_schema.py +2 -2
- package/tools/workflow_cli/stage_templates.py +11 -0
- package/tools/workflow_cli/trace.py +61 -6
- package/tools/workflow_cli/version.py +1 -1
package/README.md
CHANGED
|
@@ -68,12 +68,19 @@ existed beforehand.
|
|
|
68
68
|
### Quick start
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
|
-
r2p install
|
|
72
|
-
r2p-start "Add rate limiting"
|
|
73
|
-
r2p-continue
|
|
74
|
-
r2p status
|
|
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
|
@@ -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
|
|
369
|
-
"""
|
|
370
|
-
|
|
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
|
|
382
|
+
return None
|
|
375
383
|
try:
|
|
376
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
|
513
|
-
"""Return
|
|
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
|
-
|
|
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
|
-
|
|
603
|
+
if out is not None:
|
|
604
|
+
bodies.append("\n".join(out))
|
|
605
|
+
out = []
|
|
519
606
|
continue
|
|
520
|
-
if
|
|
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
|
-
|
|
613
|
+
bodies.append("\n".join(out))
|
|
614
|
+
out = None
|
|
615
|
+
continue
|
|
527
616
|
out.append(line.rstrip("\r\n"))
|
|
528
|
-
|
|
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):
|
|
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", "##
|
|
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
|
|
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
|
-
|
|
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(
|
|
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.
|
|
1
|
+
R2P_VERSION = "0.4.0"
|