@xenonbyte/req-2-plan 0.2.3 → 0.3.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/package.json +3 -3
- package/tools/workflow_cli/agent_shortcuts.py +57 -6
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +2 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +2 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +1 -1
- package/tools/workflow_cli/cli.py +75 -2
- package/tools/workflow_cli/context_pack.py +98 -0
- package/tools/workflow_cli/gates.py +416 -56
- package/tools/workflow_cli/link_expander.py +28 -0
- package/tools/workflow_cli/markdown.py +160 -0
- package/tools/workflow_cli/stage_schema.py +57 -0
- package/tools/workflow_cli/stage_templates.py +55 -0
- package/tools/workflow_cli/trace.py +224 -0
- package/tools/workflow_cli/version.py +1 -1
- package/docs/req-to-plan-design.md +0 -277
package/package.json
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xenonbyte/req-2-plan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Requirement-to-PLAN workflow CLI and agent integration installer.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"r2p": "bin/r2p.js"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"bin/",
|
|
10
|
-
"docs/*.md",
|
|
11
10
|
"tools/r2p",
|
|
12
11
|
"tools/r2p-*",
|
|
13
12
|
"tools/workflow_cli/**/*.py",
|
|
@@ -18,7 +17,8 @@
|
|
|
18
17
|
"README.md"
|
|
19
18
|
],
|
|
20
19
|
"scripts": {
|
|
21
|
-
"test": "
|
|
20
|
+
"test": "python -m pytest",
|
|
21
|
+
"test:local": ".venv/bin/python -m pytest",
|
|
22
22
|
"prepack": "node bin/r2p.js version >/dev/null"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
@@ -224,6 +224,33 @@ def _prepare_input_file(run_dir: Path, stage: str, suffix: str, seed: str = "")
|
|
|
224
224
|
return path
|
|
225
225
|
|
|
226
226
|
|
|
227
|
+
def _prev_stage(stage):
|
|
228
|
+
"""Return the Stage enum member immediately before *stage*, or None if first."""
|
|
229
|
+
from tools.workflow_cli.models import STAGE_ORDER
|
|
230
|
+
i = STAGE_ORDER.index(stage)
|
|
231
|
+
return STAGE_ORDER[i - 1] if i > 0 else None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _seed_for_stage(stage, tier, upstream_summary: str = "", context_summary: str = "") -> str:
|
|
235
|
+
"""Build the seed text for a stage content file: template + upstream summary + context pack."""
|
|
236
|
+
from tools.workflow_cli.stage_templates import template_for
|
|
237
|
+
base = tier.base if tier is not None else None
|
|
238
|
+
text = template_for(stage, base) if base is not None else ""
|
|
239
|
+
if upstream_summary.strip():
|
|
240
|
+
text += (
|
|
241
|
+
"\n## Upstream Summary (read-only)\n"
|
|
242
|
+
+ upstream_summary.strip()
|
|
243
|
+
+ "\n<!-- /r2p-read-only -->\n"
|
|
244
|
+
)
|
|
245
|
+
if context_summary.strip():
|
|
246
|
+
text += (
|
|
247
|
+
"\n## Project Context (read-only)\n"
|
|
248
|
+
+ context_summary.strip()
|
|
249
|
+
+ "\n<!-- /r2p-read-only -->\n"
|
|
250
|
+
)
|
|
251
|
+
return text
|
|
252
|
+
|
|
253
|
+
|
|
227
254
|
def _stage_content_command(
|
|
228
255
|
base_path: Path,
|
|
229
256
|
work_id: str,
|
|
@@ -367,6 +394,16 @@ def _resolve_start_requirement(ns: argparse.Namespace) -> tuple[str, Path | None
|
|
|
367
394
|
return raw, None
|
|
368
395
|
|
|
369
396
|
|
|
397
|
+
def _build_run_start_args(work_id, requirement, file_path, repo_path=None):
|
|
398
|
+
if file_path is not None:
|
|
399
|
+
args = ["run-start", "--work-id", work_id, "--requirement-file", str(file_path)]
|
|
400
|
+
else:
|
|
401
|
+
args = ["run-start", "--work-id", work_id, "--requirement", requirement]
|
|
402
|
+
if repo_path:
|
|
403
|
+
args += ["--repo-path", str(repo_path)]
|
|
404
|
+
return args
|
|
405
|
+
|
|
406
|
+
|
|
370
407
|
def _cmd_start(ns: argparse.Namespace, base_path: Path) -> None:
|
|
371
408
|
requirement, file_path = _resolve_start_requirement(ns)
|
|
372
409
|
|
|
@@ -387,10 +424,7 @@ def _cmd_start(ns: argparse.Namespace, base_path: Path) -> None:
|
|
|
387
424
|
sys.exit(1)
|
|
388
425
|
|
|
389
426
|
work_id = generate_work_id(requirement, base_path)
|
|
390
|
-
|
|
391
|
-
run_args = ["run-start", "--work-id", work_id, "--requirement-file", str(file_path)]
|
|
392
|
-
else:
|
|
393
|
-
run_args = ["run-start", "--work-id", work_id, "--requirement", requirement]
|
|
427
|
+
run_args = _build_run_start_args(work_id, requirement, file_path, getattr(ns, "repo_path", None))
|
|
394
428
|
exit_code = _run_cli(run_args, base_path)
|
|
395
429
|
if exit_code != 0:
|
|
396
430
|
sys.exit(exit_code)
|
|
@@ -441,7 +475,15 @@ def _cmd_continue(ns: argparse.Namespace, base_path: Path) -> None:
|
|
|
441
475
|
body = ""
|
|
442
476
|
open_owner_route = _open_owner_route(record)
|
|
443
477
|
if aa is None or not body:
|
|
444
|
-
|
|
478
|
+
prev = _prev_stage(record.current_stage)
|
|
479
|
+
try:
|
|
480
|
+
upstream = read_artifact(run_path.parent, prev) if prev else ""
|
|
481
|
+
except FileNotFoundError:
|
|
482
|
+
upstream = ""
|
|
483
|
+
pack_md = run_path.parent / "02-project-context.md"
|
|
484
|
+
context_summary = pack_md.read_text(encoding="utf-8") if pack_md.exists() else ""
|
|
485
|
+
seed = _seed_for_stage(record.current_stage, record.tier_locked, upstream, context_summary)
|
|
486
|
+
content_file = _prepare_input_file(run_path.parent, stage, "content", seed)
|
|
445
487
|
content_cmd = _stage_content_command(
|
|
446
488
|
base_path,
|
|
447
489
|
work_id,
|
|
@@ -551,7 +593,15 @@ def _cmd_continue(ns: argparse.Namespace, base_path: Path) -> None:
|
|
|
551
593
|
f"content_file: {content_file}\n"
|
|
552
594
|
f"next: {update_cmd}\n")
|
|
553
595
|
sys.exit(0)
|
|
554
|
-
|
|
596
|
+
prev = _prev_stage(record.current_stage)
|
|
597
|
+
try:
|
|
598
|
+
upstream = read_artifact(run_path.parent, prev) if prev else ""
|
|
599
|
+
except FileNotFoundError:
|
|
600
|
+
upstream = ""
|
|
601
|
+
pack_md = run_path.parent / "02-project-context.md"
|
|
602
|
+
context_summary = pack_md.read_text(encoding="utf-8") if pack_md.exists() else ""
|
|
603
|
+
seed = _seed_for_stage(record.current_stage, record.tier_locked, upstream, context_summary)
|
|
604
|
+
content_file = _prepare_input_file(run_path.parent, stage, "content", seed)
|
|
555
605
|
produce_cmd = _stage_content_command(
|
|
556
606
|
base_path,
|
|
557
607
|
work_id,
|
|
@@ -714,6 +764,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
714
764
|
default=None,
|
|
715
765
|
help="Read the requirement from a file instead of a positional argument",
|
|
716
766
|
)
|
|
767
|
+
p_start.add_argument("--repo-path", dest="repo_path", default=None)
|
|
717
768
|
|
|
718
769
|
sub.add_parser("continue")
|
|
719
770
|
|
|
@@ -8,3 +8,5 @@ Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] ("<raw requirement>" | --file <pa
|
|
|
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
|
|
|
10
10
|
Use `--separate` to create an independent run when another open run exists.
|
|
11
|
+
|
|
12
|
+
Optionally pass `--repo-path <dir>` to ground tier estimation and the Project Context Pack in real repo facts.
|
|
@@ -12,3 +12,5 @@ Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] ("<raw requirement>" | --file <pa
|
|
|
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
|
|
|
14
14
|
Use `--separate` to create an independent run when another open run exists.
|
|
15
|
+
|
|
16
|
+
Optionally pass `--repo-path <dir>` to ground tier estimation and the Project Context Pack in real repo facts.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
name = "r2p-start"
|
|
2
|
-
description = "Start a new requirement-to-PLAN workflow run"
|
|
2
|
+
description = "Start a new requirement-to-PLAN workflow run. Optionally pass --repo-path <dir> to ground tier estimation and the Project Context Pack in real repo facts."
|
|
3
3
|
command = "{{R2P_BIN_DIR}}/r2p-start"
|
|
4
4
|
version = "{{R2P_VERSION}}"
|
|
@@ -90,6 +90,20 @@ def _validate_work_id(raw: str) -> WorkId:
|
|
|
90
90
|
print_and_exit(format_error(str(e), exit_code=EXIT_CLI_ERR), EXIT_CLI_ERR)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def _validate_repo_path(raw: str) -> Path:
|
|
94
|
+
"""Return a repo path only when it is an existing directory."""
|
|
95
|
+
repo_path = Path(raw)
|
|
96
|
+
if not repo_path.is_dir():
|
|
97
|
+
print_and_exit(
|
|
98
|
+
format_error(
|
|
99
|
+
f"repo path not found or not a directory: {raw}",
|
|
100
|
+
exit_code=EXIT_CLI_ERR,
|
|
101
|
+
),
|
|
102
|
+
EXIT_CLI_ERR,
|
|
103
|
+
)
|
|
104
|
+
return repo_path
|
|
105
|
+
|
|
106
|
+
|
|
93
107
|
def _parse_stage(raw: str) -> Stage:
|
|
94
108
|
"""Parse Stage enum or exit with CLI error."""
|
|
95
109
|
try:
|
|
@@ -180,6 +194,7 @@ def _cmd_run_start(args):
|
|
|
180
194
|
format_error("Requirement must not be blank", exit_code=EXIT_CLI_ERR),
|
|
181
195
|
EXIT_CLI_ERR,
|
|
182
196
|
)
|
|
197
|
+
repo_path = _validate_repo_path(args.repo_path) if args.repo_path else None
|
|
183
198
|
run_dir = _get_run_dir(work_id, args.base_path)
|
|
184
199
|
mgr = RunStateManager(run_dir)
|
|
185
200
|
|
|
@@ -212,11 +227,29 @@ def _cmd_run_start(args):
|
|
|
212
227
|
run_dir.mkdir(parents=True, exist_ok=True)
|
|
213
228
|
write_artifact(run_dir, Stage.RAW_REQUIREMENT, requirement, version=1, status="draft")
|
|
214
229
|
|
|
230
|
+
# Tier estimation inputs
|
|
231
|
+
link_results = []
|
|
232
|
+
if repo_path is not None:
|
|
233
|
+
from tools.workflow_cli.link_expander import expand_links
|
|
234
|
+
# Local relative links expand; HTTP is recorded as not-expanded (needs confirmation).
|
|
235
|
+
link_results = expand_links(requirement, base_path=repo_path, fetch_urls=False)
|
|
236
|
+
|
|
215
237
|
# Tier estimation
|
|
216
|
-
|
|
217
|
-
tier_estimate, evidence = estimate_tier(requirement, repo_path=repo_path)
|
|
238
|
+
tier_estimate, evidence = estimate_tier(requirement, repo_path=repo_path, link_results=link_results)
|
|
218
239
|
record.tier_estimate = tier_estimate
|
|
219
240
|
|
|
241
|
+
# Context Pack + link expansion (when repo_path is provided)
|
|
242
|
+
if repo_path is not None:
|
|
243
|
+
from tools.workflow_cli.context_pack import build_context_pack, write_context_pack
|
|
244
|
+
write_context_pack(build_context_pack(repo_path), run_dir)
|
|
245
|
+
if link_results:
|
|
246
|
+
evidence.linked_context = "\n".join(
|
|
247
|
+
f"- {r.url}: {r.status.value}"
|
|
248
|
+
+ (f"\n preview: {r.content_preview}" if r.content_preview else "")
|
|
249
|
+
+ (f"\n error: {r.error}" if r.error else "")
|
|
250
|
+
for r in link_results
|
|
251
|
+
)
|
|
252
|
+
|
|
220
253
|
# Update active artifacts in record
|
|
221
254
|
artifact_file = STAGE_ARTIFACT_MAP[Stage.RAW_REQUIREMENT]
|
|
222
255
|
upsert_active_artifact(record, Stage.RAW_REQUIREMENT, artifact_file, 1, "draft")
|
|
@@ -421,6 +454,10 @@ def _cmd_run_reopen(args):
|
|
|
421
454
|
src_path = source_dir / artifact_file
|
|
422
455
|
if src_path.exists():
|
|
423
456
|
shutil.copy2(src_path, new_run_dir / artifact_file)
|
|
457
|
+
for context_file in ("02-project-context.json", "02-project-context.md"):
|
|
458
|
+
src_path = source_dir / context_file
|
|
459
|
+
if src_path.exists():
|
|
460
|
+
shutil.copy2(src_path, new_run_dir / context_file)
|
|
424
461
|
|
|
425
462
|
# Create new run record
|
|
426
463
|
new_record = create_run_record(new_work_id)
|
|
@@ -1745,6 +1782,41 @@ def _register_route_commands(subparsers):
|
|
|
1745
1782
|
p.set_defaults(func=_cmd_gap_resolve)
|
|
1746
1783
|
|
|
1747
1784
|
|
|
1785
|
+
# ---------------------------------------------------------------------------
|
|
1786
|
+
# context-build command
|
|
1787
|
+
# ---------------------------------------------------------------------------
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def _cmd_context_build(args):
|
|
1791
|
+
from tools.workflow_cli.context_pack import build_context_pack, write_context_pack
|
|
1792
|
+
|
|
1793
|
+
work_id = str(_validate_work_id(args.work_id))
|
|
1794
|
+
run_dir = _get_run_dir(work_id, args.base_path)
|
|
1795
|
+
if not run_dir.exists():
|
|
1796
|
+
print_and_exit(
|
|
1797
|
+
format_error(f"run not found: {work_id}", exit_code=EXIT_NOT_FOUND),
|
|
1798
|
+
EXIT_NOT_FOUND,
|
|
1799
|
+
)
|
|
1800
|
+
repo_path = _validate_repo_path(args.repo_path)
|
|
1801
|
+
pack = build_context_pack(repo_path)
|
|
1802
|
+
md_path, json_path = write_context_pack(pack, run_dir)
|
|
1803
|
+
print_and_exit(
|
|
1804
|
+
format_success(
|
|
1805
|
+
{"work_id": work_id, "context_md": str(md_path), "context_json": str(json_path)},
|
|
1806
|
+
message="Context Pack built",
|
|
1807
|
+
),
|
|
1808
|
+
EXIT_OK,
|
|
1809
|
+
)
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def _register_context_commands(subparsers):
|
|
1813
|
+
p = subparsers.add_parser("context-build", help="Build Project Context Pack for a run")
|
|
1814
|
+
p.add_argument("--work-id", required=True)
|
|
1815
|
+
p.add_argument("--repo-path", required=True)
|
|
1816
|
+
p.add_argument("--base-path", type=Path, default=argparse.SUPPRESS)
|
|
1817
|
+
p.set_defaults(func=_cmd_context_build)
|
|
1818
|
+
|
|
1819
|
+
|
|
1748
1820
|
# ---------------------------------------------------------------------------
|
|
1749
1821
|
# main
|
|
1750
1822
|
# ---------------------------------------------------------------------------
|
|
@@ -1770,6 +1842,7 @@ def main(args=None):
|
|
|
1770
1842
|
_register_status_commands(subparsers)
|
|
1771
1843
|
_register_stage_commands(subparsers)
|
|
1772
1844
|
_register_checkpoint_commands(subparsers)
|
|
1845
|
+
_register_context_commands(subparsers)
|
|
1773
1846
|
|
|
1774
1847
|
parsed = parser.parse_args(args)
|
|
1775
1848
|
parsed.func(parsed)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Build a lightweight Project Context Pack (v1: no AST symbols)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from tools.workflow_cli.repo_baseline import SKIP_DIRS, scan_repo_baseline
|
|
10
|
+
|
|
11
|
+
_CONFIG_NAMES = {
|
|
12
|
+
"pyproject.toml", "setup.cfg", "tox.ini", "Cargo.toml", "go.mod",
|
|
13
|
+
"tsconfig.json", ".env.example", "Dockerfile", "Makefile", "requirements.txt",
|
|
14
|
+
}
|
|
15
|
+
_ENTRYPOINT_HINTS = ("main.py", "__main__.py", "index.ts", "index.js", "app.py", "server.ts")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ProjectContextPack:
|
|
20
|
+
repo_root: str = "."
|
|
21
|
+
languages: dict = field(default_factory=dict)
|
|
22
|
+
package_managers: list = field(default_factory=list)
|
|
23
|
+
test_commands: list = field(default_factory=list)
|
|
24
|
+
entrypoints: list = field(default_factory=list)
|
|
25
|
+
dependencies: list = field(default_factory=list) # [{name, version, ecosystem}]
|
|
26
|
+
config_files: list = field(default_factory=list)
|
|
27
|
+
source_dirs: list = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_context_pack(repo_path: Path) -> ProjectContextPack:
|
|
31
|
+
repo_path = Path(repo_path).resolve()
|
|
32
|
+
baseline = scan_repo_baseline(repo_path)
|
|
33
|
+
pack = ProjectContextPack(repo_root=str(repo_path), languages=dict(baseline.language_breakdown))
|
|
34
|
+
|
|
35
|
+
pkg = repo_path / "package.json"
|
|
36
|
+
if pkg.exists():
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
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"})
|
|
45
|
+
except (ValueError, OSError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
req = repo_path / "requirements.txt"
|
|
49
|
+
if req.exists():
|
|
50
|
+
pack.package_managers.append("pip")
|
|
51
|
+
for line in req.read_text(encoding="utf-8").splitlines():
|
|
52
|
+
line = line.strip()
|
|
53
|
+
if line and not line.startswith(("#", "-")):
|
|
54
|
+
pack.dependencies.append({"name": line, "version": "", "ecosystem": "pip"})
|
|
55
|
+
if not pack.test_commands:
|
|
56
|
+
pack.test_commands.append("python -m pytest")
|
|
57
|
+
|
|
58
|
+
for root, dirs, files in os.walk(repo_path):
|
|
59
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
|
|
60
|
+
rel_root = Path(root).relative_to(repo_path)
|
|
61
|
+
for fname in files:
|
|
62
|
+
rel = str(rel_root / fname) if str(rel_root) != "." else fname
|
|
63
|
+
if fname in _CONFIG_NAMES:
|
|
64
|
+
pack.config_files.append(rel)
|
|
65
|
+
if fname in _ENTRYPOINT_HINTS:
|
|
66
|
+
pack.entrypoints.append(rel)
|
|
67
|
+
|
|
68
|
+
pack.source_dirs = sorted(
|
|
69
|
+
p.name for p in repo_path.iterdir()
|
|
70
|
+
if p.is_dir() and p.name not in SKIP_DIRS and not p.name.startswith(".")
|
|
71
|
+
)
|
|
72
|
+
return pack
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def to_json(pack: ProjectContextPack) -> str:
|
|
76
|
+
return json.dumps(asdict(pack), indent=2, ensure_ascii=False)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def to_markdown(pack: ProjectContextPack) -> str:
|
|
80
|
+
return (
|
|
81
|
+
"# Project Context Pack\n\n"
|
|
82
|
+
f"- repo_root: `{pack.repo_root}`\n"
|
|
83
|
+
f"- languages: {pack.languages}\n"
|
|
84
|
+
f"- package_managers: {', '.join(pack.package_managers) or 'none'}\n"
|
|
85
|
+
f"- test_commands: {pack.test_commands or 'none'}\n"
|
|
86
|
+
f"- entrypoints: {pack.entrypoints or 'none'}\n"
|
|
87
|
+
f"- config_files: {pack.config_files or 'none'}\n"
|
|
88
|
+
f"- dependencies: {len(pack.dependencies)} found\n"
|
|
89
|
+
f"- source_dirs: {pack.source_dirs}\n"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def write_context_pack(pack: ProjectContextPack, run_dir: Path) -> tuple[Path, Path]:
|
|
94
|
+
json_path = run_dir / "02-project-context.json"
|
|
95
|
+
md_path = run_dir / "02-project-context.md"
|
|
96
|
+
json_path.write_text(to_json(pack), encoding="utf-8")
|
|
97
|
+
md_path.write_text(to_markdown(pack), encoding="utf-8")
|
|
98
|
+
return md_path, json_path
|