@xenonbyte/req-2-plan 0.4.1 → 0.4.3
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/package.json +1 -1
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +1 -1
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +1 -1
- package/tools/workflow_cli/context_pack.py +76 -10
- package/tools/workflow_cli/gates.py +44 -16
- package/tools/workflow_cli/version.py +1 -1
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ description: Start a new requirement-to-PLAN workflow run
|
|
|
3
3
|
---
|
|
4
4
|
Run `{{R2P_BIN_DIR}}/r2p-start` with the requirement as a positional argument.
|
|
5
5
|
|
|
6
|
-
Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] ("<raw requirement>" | --file <path>)`
|
|
6
|
+
Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] [--repo-path <dir>] ("<raw requirement>" | --file <path>)`
|
|
7
7
|
|
|
8
8
|
To start from a requirement document, pass `--file <path>` instead of inline text — r2p reads the file contents as the requirement (the path itself is never stored as the requirement): `{{R2P_BIN_DIR}}/r2p-start [--separate] --file ./requirement.md`. `--file` and a positional requirement are mutually exclusive.
|
|
9
9
|
|
|
@@ -7,7 +7,7 @@ description: Start a new requirement-to-PLAN workflow run from a raw requirement
|
|
|
7
7
|
|
|
8
8
|
Run `{{R2P_BIN_DIR}}/r2p-start` with the requirement as a positional argument.
|
|
9
9
|
|
|
10
|
-
Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] ("<raw requirement>" | --file <path>)`
|
|
10
|
+
Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] [--repo-path <dir>] ("<raw requirement>" | --file <path>)`
|
|
11
11
|
|
|
12
12
|
To start from a requirement document, pass `--file <path>` instead of inline text — r2p reads the file contents as the requirement (the path itself is never stored as the requirement): `{{R2P_BIN_DIR}}/r2p-start [--separate] --file ./requirement.md`. `--file` and a positional requirement are mutually exclusive.
|
|
13
13
|
|
|
@@ -6,6 +6,11 @@ import os
|
|
|
6
6
|
from dataclasses import asdict, dataclass, field
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
try:
|
|
10
|
+
import tomllib # Python 3.11+
|
|
11
|
+
except ImportError: # pragma: no cover - older interpreters: pyproject parsing degrades to a no-op
|
|
12
|
+
tomllib = None
|
|
13
|
+
|
|
9
14
|
from tools.workflow_cli.repo_baseline import SKIP_DIRS, scan_repo_baseline
|
|
10
15
|
|
|
11
16
|
_CONFIG_NAMES = {
|
|
@@ -27,6 +32,53 @@ class ProjectContextPack:
|
|
|
27
32
|
source_dirs: list = field(default_factory=list)
|
|
28
33
|
|
|
29
34
|
|
|
35
|
+
def _append_npm_dependencies(pack: ProjectContextPack, dependencies: object, *, dev: bool = False) -> None:
|
|
36
|
+
if not isinstance(dependencies, dict):
|
|
37
|
+
return
|
|
38
|
+
for name, version in dependencies.items():
|
|
39
|
+
if not isinstance(name, str) or not isinstance(version, str):
|
|
40
|
+
continue
|
|
41
|
+
dep = {"name": name, "version": version, "ecosystem": "npm"}
|
|
42
|
+
if dev:
|
|
43
|
+
dep["dev"] = True
|
|
44
|
+
pack.dependencies.append(dep)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _append_pyproject_facts(pack: ProjectContextPack, repo_path: Path) -> None:
|
|
48
|
+
"""Collect PEP 621 [project] dependencies and a pytest signal from pyproject.toml.
|
|
49
|
+
|
|
50
|
+
Poetry/PDM private tables are out of scope; without tomllib (Python < 3.11)
|
|
51
|
+
this is a no-op, matching the pack's best-effort scan semantics."""
|
|
52
|
+
pyproject = repo_path / "pyproject.toml"
|
|
53
|
+
if tomllib is None or not pyproject.exists():
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
57
|
+
except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError):
|
|
58
|
+
return
|
|
59
|
+
project = data.get("project")
|
|
60
|
+
project = project if isinstance(project, dict) else {}
|
|
61
|
+
raw_deps = project.get("dependencies")
|
|
62
|
+
specs = [s for s in raw_deps if isinstance(s, str)] if isinstance(raw_deps, list) else []
|
|
63
|
+
optional = project.get("optional-dependencies")
|
|
64
|
+
optional_specs: list[str] = []
|
|
65
|
+
if isinstance(optional, dict):
|
|
66
|
+
for group in optional.values():
|
|
67
|
+
if isinstance(group, list):
|
|
68
|
+
optional_specs.extend(s for s in group if isinstance(s, str))
|
|
69
|
+
if (specs or optional_specs) and "pip" not in pack.package_managers:
|
|
70
|
+
pack.package_managers.append("pip")
|
|
71
|
+
for spec in specs:
|
|
72
|
+
pack.dependencies.append({"name": spec, "version": "", "ecosystem": "pip"})
|
|
73
|
+
for spec in optional_specs:
|
|
74
|
+
pack.dependencies.append({"name": spec, "version": "", "ecosystem": "pip", "dev": True})
|
|
75
|
+
tool = data.get("tool")
|
|
76
|
+
has_pytest_config = isinstance(tool, dict) and isinstance(tool.get("pytest"), dict)
|
|
77
|
+
mentions_pytest = any(s.lower().startswith("pytest") for s in specs + optional_specs)
|
|
78
|
+
if (has_pytest_config or mentions_pytest) and "python -m pytest" not in pack.test_commands:
|
|
79
|
+
pack.test_commands.append("python -m pytest")
|
|
80
|
+
|
|
81
|
+
|
|
30
82
|
def build_context_pack(repo_path: Path) -> ProjectContextPack:
|
|
31
83
|
repo_path = Path(repo_path).resolve()
|
|
32
84
|
baseline = scan_repo_baseline(repo_path)
|
|
@@ -41,15 +93,8 @@ def build_context_pack(repo_path: Path) -> ProjectContextPack:
|
|
|
41
93
|
scripts = data.get("scripts")
|
|
42
94
|
if isinstance(scripts, dict) and scripts.get("test"):
|
|
43
95
|
pack.test_commands.append("npm test")
|
|
44
|
-
|
|
45
|
-
|
|
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})
|
|
96
|
+
_append_npm_dependencies(pack, data.get("dependencies"))
|
|
97
|
+
_append_npm_dependencies(pack, data.get("devDependencies"), dev=True)
|
|
53
98
|
except (ValueError, OSError):
|
|
54
99
|
pass
|
|
55
100
|
|
|
@@ -63,6 +108,8 @@ def build_context_pack(repo_path: Path) -> ProjectContextPack:
|
|
|
63
108
|
if not pack.test_commands:
|
|
64
109
|
pack.test_commands.append("python -m pytest")
|
|
65
110
|
|
|
111
|
+
_append_pyproject_facts(pack, repo_path)
|
|
112
|
+
|
|
66
113
|
for root, dirs, files in os.walk(repo_path):
|
|
67
114
|
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
|
|
68
115
|
rel_root = Path(root).relative_to(repo_path)
|
|
@@ -84,6 +131,25 @@ def to_json(pack: ProjectContextPack) -> str:
|
|
|
84
131
|
return json.dumps(asdict(pack), indent=2, ensure_ascii=False)
|
|
85
132
|
|
|
86
133
|
|
|
134
|
+
_DEP_DISPLAY_CAP = 20
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _dependencies_markdown(dependencies: list) -> str:
|
|
138
|
+
"""Itemize dependencies (name, version, ecosystem, dev flag), capped to keep the seed small."""
|
|
139
|
+
if not dependencies:
|
|
140
|
+
return "- dependencies (0): none\n"
|
|
141
|
+
lines = [f"- dependencies ({len(dependencies)}):\n"]
|
|
142
|
+
for dep in dependencies[:_DEP_DISPLAY_CAP]:
|
|
143
|
+
label = " ".join(part for part in (dep.get("name", ""), dep.get("version", "")) if part)
|
|
144
|
+
ecosystem = dep.get("ecosystem", "")
|
|
145
|
+
suffix = f"{ecosystem}, dev" if dep.get("dev") else ecosystem
|
|
146
|
+
lines.append(f" - {label} ({suffix})\n")
|
|
147
|
+
hidden = len(dependencies) - _DEP_DISPLAY_CAP
|
|
148
|
+
if hidden > 0:
|
|
149
|
+
lines.append(f" - … and {hidden} more\n")
|
|
150
|
+
return "".join(lines)
|
|
151
|
+
|
|
152
|
+
|
|
87
153
|
def to_markdown(pack: ProjectContextPack) -> str:
|
|
88
154
|
return (
|
|
89
155
|
"# Project Context Pack\n\n"
|
|
@@ -93,7 +159,7 @@ def to_markdown(pack: ProjectContextPack) -> str:
|
|
|
93
159
|
f"- test_commands: {pack.test_commands or 'none'}\n"
|
|
94
160
|
f"- entrypoints: {pack.entrypoints or 'none'}\n"
|
|
95
161
|
f"- config_files: {pack.config_files or 'none'}\n"
|
|
96
|
-
f"
|
|
162
|
+
f"{_dependencies_markdown(pack.dependencies)}"
|
|
97
163
|
f"- source_dirs: {pack.source_dirs}\n"
|
|
98
164
|
)
|
|
99
165
|
|
|
@@ -290,14 +290,6 @@ def _find_next_plan_task_field_start(task_body: str, after: int) -> int | None:
|
|
|
290
290
|
return None
|
|
291
291
|
|
|
292
292
|
|
|
293
|
-
def _plan_task_field_value(task_body: str, field: str) -> str:
|
|
294
|
-
found = _find_plan_task_field(task_body, field)
|
|
295
|
-
if found is None:
|
|
296
|
-
return ""
|
|
297
|
-
match, _ = found
|
|
298
|
-
return match.group(1).strip()
|
|
299
|
-
|
|
300
|
-
|
|
301
293
|
def _iter_plan_task_bodies(content: str):
|
|
302
294
|
return heading_bounded_bodies(content, _PLAN_TASK_RE.match)
|
|
303
295
|
|
|
@@ -439,13 +431,14 @@ def _check_plan_context_pack(run_dir: Path) -> list[str]:
|
|
|
439
431
|
|
|
440
432
|
def _check_plan_file_refs(run_dir: Path, content: str) -> list[str]:
|
|
441
433
|
"""Hard-check Files paths against the Context Pack repo_root. create-type tasks
|
|
442
|
-
|
|
434
|
+
must target paths that do not exist yet (R19); the part after '::' (a symbol) is
|
|
435
|
+
advisory and not checked (no AST pack yet)."""
|
|
443
436
|
repo_root = _context_pack_repo_root(run_dir)
|
|
444
437
|
if repo_root is None:
|
|
445
438
|
return [] # no usable ground truth; standard tier blocks via _check_plan_context_pack
|
|
446
439
|
issues: list[str] = []
|
|
447
440
|
for body in _iter_plan_task_bodies(content):
|
|
448
|
-
|
|
441
|
+
is_create = _normalized_change_type(_task_change_type(body)) == "create"
|
|
449
442
|
files_field = _plan_task_field_body(body, "Files")
|
|
450
443
|
for path_part in _plan_task_file_paths(files_field):
|
|
451
444
|
path = Path(path_part)
|
|
@@ -461,11 +454,18 @@ def _check_plan_file_refs(run_dir: Path, content: str) -> list[str]:
|
|
|
461
454
|
)
|
|
462
455
|
continue
|
|
463
456
|
if not resolved.exists():
|
|
464
|
-
if not
|
|
457
|
+
if not is_create:
|
|
465
458
|
issues.append(
|
|
466
459
|
f"PLAN-TASK Files references missing path {path_part!r} "
|
|
467
460
|
"(mark the task 'Change Type: create' if it is a new file)."
|
|
468
461
|
)
|
|
462
|
+
elif is_create:
|
|
463
|
+
# R19: create must not silently mean "overwrite an existing file".
|
|
464
|
+
issues.append(
|
|
465
|
+
f"PLAN-TASK Files references path {path_part!r} that already exists "
|
|
466
|
+
"under 'Change Type: create'; use 'modify' for existing files or "
|
|
467
|
+
"split the task."
|
|
468
|
+
)
|
|
469
469
|
return issues
|
|
470
470
|
|
|
471
471
|
|
|
@@ -496,8 +496,10 @@ def _check_spec_refs_valid(run_dir: Path, content: str) -> list[str]:
|
|
|
496
496
|
|
|
497
497
|
|
|
498
498
|
# R10: Change Type is a closed operation-kind enum; 'new' is a legacy alias.
|
|
499
|
-
|
|
499
|
+
_FILE_CHANGE_TYPES = frozenset({"create", "modify", "delete"})
|
|
500
|
+
_CHANGE_TYPE_VALUES = _FILE_CHANGE_TYPES | {"non_code"}
|
|
500
501
|
_CHANGE_TYPE_ALIASES = {"new": "create"}
|
|
502
|
+
_TDD_APPLICABLE_VALUES = frozenset({"yes", "no"})
|
|
501
503
|
|
|
502
504
|
|
|
503
505
|
def _normalized_change_type(raw: str) -> str:
|
|
@@ -510,6 +512,11 @@ def _task_change_type(body: str) -> str:
|
|
|
510
512
|
return " ".join(_plan_task_field_body(body, "Change Type").split())
|
|
511
513
|
|
|
512
514
|
|
|
515
|
+
def _task_tdd_applicable(body: str) -> str:
|
|
516
|
+
"""Whitespace-normalized TDD Applicable field body (same line + continuation lines)."""
|
|
517
|
+
return " ".join(_plan_task_field_body(body, "TDD Applicable").split())
|
|
518
|
+
|
|
519
|
+
|
|
513
520
|
def _check_plan_task_fields(content: str) -> list[str]:
|
|
514
521
|
issues: list[str] = []
|
|
515
522
|
numbers: list[int] = []
|
|
@@ -523,10 +530,32 @@ def _check_plan_task_fields(content: str) -> list[str]:
|
|
|
523
530
|
if not _plan_task_field_body(body, field).strip():
|
|
524
531
|
issues.append(f"{label} is missing a non-empty '{field}:' field.")
|
|
525
532
|
raw_change_type = _task_change_type(body)
|
|
526
|
-
|
|
533
|
+
change_type = _normalized_change_type(raw_change_type)
|
|
534
|
+
if raw_change_type and change_type not in _CHANGE_TYPE_VALUES:
|
|
527
535
|
issues.append(
|
|
528
536
|
f"{label} has invalid 'Change Type: {raw_change_type}'; "
|
|
529
|
-
"allowed: create|modify|delete (alias: new = create)."
|
|
537
|
+
"allowed: create|modify|delete|non_code (alias: new = create)."
|
|
538
|
+
)
|
|
539
|
+
# R16: Change Type and Files must agree — file-op types need a real
|
|
540
|
+
# path; non_code is the only legal shape for no-file tasks.
|
|
541
|
+
files_body = _plan_task_field_body(body, "Files")
|
|
542
|
+
if files_body.strip():
|
|
543
|
+
file_paths = _plan_task_file_paths(files_body)
|
|
544
|
+
if change_type in _FILE_CHANGE_TYPES and not file_paths:
|
|
545
|
+
issues.append(
|
|
546
|
+
f"{label} has 'Change Type: {raw_change_type}' but 'Files:' lists "
|
|
547
|
+
"no real file path; use 'Change Type: non_code' for tasks that "
|
|
548
|
+
"touch no files."
|
|
549
|
+
)
|
|
550
|
+
elif change_type == "non_code" and file_paths:
|
|
551
|
+
issues.append(
|
|
552
|
+
f"{label} has 'Change Type: non_code' but 'Files:' lists a file path; "
|
|
553
|
+
"use create|modify|delete for file-touching tasks."
|
|
554
|
+
)
|
|
555
|
+
raw_tdd = _task_tdd_applicable(body)
|
|
556
|
+
if raw_tdd and raw_tdd.lower() not in _TDD_APPLICABLE_VALUES:
|
|
557
|
+
issues.append(
|
|
558
|
+
f"{label} has invalid 'TDD Applicable: {raw_tdd}'; allowed: yes|no."
|
|
530
559
|
)
|
|
531
560
|
if numbers:
|
|
532
561
|
if len(set(numbers)) != len(numbers):
|
|
@@ -575,8 +604,7 @@ def _plan_tasks_missing_code(content: str) -> bool:
|
|
|
575
604
|
return False
|
|
576
605
|
for body in bodies:
|
|
577
606
|
skeleton = _plan_task_field_body(body, "Skeleton")
|
|
578
|
-
|
|
579
|
-
if tdd_applicable.lower() == "yes" and not _has_complete_code_fence(skeleton):
|
|
607
|
+
if _task_tdd_applicable(body).lower() == "yes" and not _has_complete_code_fence(skeleton):
|
|
580
608
|
return True
|
|
581
609
|
return False
|
|
582
610
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
R2P_VERSION = "0.4.
|
|
1
|
+
R2P_VERSION = "0.4.3"
|