@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 CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "@xenonbyte/req-2-plan",
3
- "version": "0.2.3",
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": ".venv/bin/python -m pytest",
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
- if file_path is not None:
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
- content_file = _prepare_input_file(run_path.parent, stage, "content")
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
- content_file = _prepare_input_file(run_path.parent, stage, "content")
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
- repo_path = Path(args.repo_path) if args.repo_path else None
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