@xenonbyte/req-2-plan 0.4.0 → 0.4.2

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,17 +67,31 @@ 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" --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
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
 
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
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
81
95
  the `PYTHONPATH=... <python> -m tools.workflow_cli context-build ...` command printed
82
96
  by the gate (there is no standalone `context-build` executable).
83
97
 
@@ -174,6 +188,16 @@ r2p-gap-resolve --work-id <id> --route-id R-1
174
188
  > walks you through both repair flows with `needs_repair` and `needs_gap_resolve`
175
189
  > stops.
176
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
+
177
201
  ## License
178
202
 
179
203
  [MIT](./LICENSE) © xenonbyte
package/README.zh-CN.md CHANGED
@@ -61,16 +61,28 @@ 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" --repo-path . # 启动一次以当前仓库事实为锚点的工作流
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
 
71
- 需求针对当前项目时必传 `--repo-path .`(跨仓库需求传目标仓库路径);它生成的
72
- Project Context Pack 是 tier 估算与 PLAN 文件引用校验的真值锚点。若 standard tier
73
- PLAN gate 提示 Context Pack 缺失/不可用,直接执行 gate 打印的
80
+ 需求可以是内联文本,也可以用 `--file <path>` 传入文档(两者互斥)。
81
+ **只要需求以某个代码仓库为上下文,就必须传 `--repo-path`**——当前项目传 `.`,
82
+ 跨仓库需求传目标仓库路径;它生成的 Project Context Pack tier 估算与 PLAN
83
+ 文件引用校验的真值锚点。选项写在需求文本之前(如上例),这样即使自由文本
84
+ 引号写错也不会吞掉选项。若 standard tier 的 PLAN gate 提示 Context Pack
85
+ 缺失/不可用,直接执行 gate 打印的
74
86
  `PYTHONPATH=... <python> -m tools.workflow_cli context-build ...` 命令中途补建
75
87
  (不存在独立的 `context-build` 可执行文件)。
76
88
 
@@ -159,6 +171,15 @@ r2p-gap-resolve --work-id <id> --route-id R-1
159
171
  > reopen 针对**已关闭**的 run;gap 路由针对**开着**的 run。`r2p-continue` 会用
160
172
  > `needs_repair` 和 `needs_gap_resolve` 停点带你走完这两种修复流程。
161
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
+
162
183
  ## License
163
184
 
164
185
  [MIT](./LICENSE) © xenonbyte
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenonbyte/req-2-plan",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Requirement-to-PLAN workflow CLI and agent integration installer.",
5
5
  "bin": {
6
6
  "r2p": "bin/r2p.js"
@@ -33,4 +33,5 @@ Run each via Bash using the scripts in `{{R2P_BIN_DIR}}`:
33
33
  - Auto-advances: moves to the next stage after non-PLAN checkpoint approval, then runs that stage's entry gate
34
34
  - Auto-closes: closes the run when the PLAN checkpoint is approved and no open routes remain
35
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
36
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.
@@ -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
 
@@ -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,11 +22,23 @@ 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
 
29
29
 
30
+ def _append_npm_dependencies(pack: ProjectContextPack, dependencies: object, *, dev: bool = False) -> None:
31
+ if not isinstance(dependencies, dict):
32
+ return
33
+ for name, version in dependencies.items():
34
+ if not isinstance(name, str) or not isinstance(version, str):
35
+ continue
36
+ dep = {"name": name, "version": version, "ecosystem": "npm"}
37
+ if dev:
38
+ dep["dev"] = True
39
+ pack.dependencies.append(dep)
40
+
41
+
30
42
  def build_context_pack(repo_path: Path) -> ProjectContextPack:
31
43
  repo_path = Path(repo_path).resolve()
32
44
  baseline = scan_repo_baseline(repo_path)
@@ -35,13 +47,14 @@ def build_context_pack(repo_path: Path) -> ProjectContextPack:
35
47
  pkg = repo_path / "package.json"
36
48
  if pkg.exists():
37
49
  try:
38
- data = json.loads(pkg.read_text(encoding="utf-8"))
50
+ raw_data = json.loads(pkg.read_text(encoding="utf-8"))
39
51
  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"})
52
+ data = raw_data if isinstance(raw_data, dict) else {}
53
+ scripts = data.get("scripts")
54
+ if isinstance(scripts, dict) and scripts.get("test"):
55
+ pack.test_commands.append("npm test")
56
+ _append_npm_dependencies(pack, data.get("dependencies"))
57
+ _append_npm_dependencies(pack, data.get("devDependencies"), dev=True)
45
58
  except (ValueError, OSError):
46
59
  pass
47
60
 
@@ -76,6 +89,25 @@ def to_json(pack: ProjectContextPack) -> str:
76
89
  return json.dumps(asdict(pack), indent=2, ensure_ascii=False)
77
90
 
78
91
 
92
+ _DEP_DISPLAY_CAP = 20
93
+
94
+
95
+ def _dependencies_markdown(dependencies: list) -> str:
96
+ """Itemize dependencies (name, version, ecosystem, dev flag), capped to keep the seed small."""
97
+ if not dependencies:
98
+ return "- dependencies (0): none\n"
99
+ lines = [f"- dependencies ({len(dependencies)}):\n"]
100
+ for dep in dependencies[:_DEP_DISPLAY_CAP]:
101
+ label = " ".join(part for part in (dep.get("name", ""), dep.get("version", "")) if part)
102
+ ecosystem = dep.get("ecosystem", "")
103
+ suffix = f"{ecosystem}, dev" if dep.get("dev") else ecosystem
104
+ lines.append(f" - {label} ({suffix})\n")
105
+ hidden = len(dependencies) - _DEP_DISPLAY_CAP
106
+ if hidden > 0:
107
+ lines.append(f" - … and {hidden} more\n")
108
+ return "".join(lines)
109
+
110
+
79
111
  def to_markdown(pack: ProjectContextPack) -> str:
80
112
  return (
81
113
  "# Project Context Pack\n\n"
@@ -85,7 +117,7 @@ def to_markdown(pack: ProjectContextPack) -> str:
85
117
  f"- test_commands: {pack.test_commands or 'none'}\n"
86
118
  f"- entrypoints: {pack.entrypoints or 'none'}\n"
87
119
  f"- config_files: {pack.config_files or 'none'}\n"
88
- f"- dependencies: {len(pack.dependencies)} found\n"
120
+ f"{_dependencies_markdown(pack.dependencies)}"
89
121
  f"- source_dirs: {pack.source_dirs}\n"
90
122
  )
91
123
 
@@ -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
 
@@ -496,8 +488,10 @@ def _check_spec_refs_valid(run_dir: Path, content: str) -> list[str]:
496
488
 
497
489
 
498
490
  # R10: Change Type is a closed operation-kind enum; 'new' is a legacy alias.
499
- _CHANGE_TYPE_VALUES = frozenset({"create", "modify", "delete"})
491
+ _FILE_CHANGE_TYPES = frozenset({"create", "modify", "delete"})
492
+ _CHANGE_TYPE_VALUES = _FILE_CHANGE_TYPES | {"non_code"}
500
493
  _CHANGE_TYPE_ALIASES = {"new": "create"}
494
+ _TDD_APPLICABLE_VALUES = frozenset({"yes", "no"})
501
495
 
502
496
 
503
497
  def _normalized_change_type(raw: str) -> str:
@@ -510,6 +504,11 @@ def _task_change_type(body: str) -> str:
510
504
  return " ".join(_plan_task_field_body(body, "Change Type").split())
511
505
 
512
506
 
507
+ def _task_tdd_applicable(body: str) -> str:
508
+ """Whitespace-normalized TDD Applicable field body (same line + continuation lines)."""
509
+ return " ".join(_plan_task_field_body(body, "TDD Applicable").split())
510
+
511
+
513
512
  def _check_plan_task_fields(content: str) -> list[str]:
514
513
  issues: list[str] = []
515
514
  numbers: list[int] = []
@@ -523,10 +522,32 @@ def _check_plan_task_fields(content: str) -> list[str]:
523
522
  if not _plan_task_field_body(body, field).strip():
524
523
  issues.append(f"{label} is missing a non-empty '{field}:' field.")
525
524
  raw_change_type = _task_change_type(body)
526
- if raw_change_type and _normalized_change_type(raw_change_type) not in _CHANGE_TYPE_VALUES:
525
+ change_type = _normalized_change_type(raw_change_type)
526
+ if raw_change_type and change_type not in _CHANGE_TYPE_VALUES:
527
527
  issues.append(
528
528
  f"{label} has invalid 'Change Type: {raw_change_type}'; "
529
- "allowed: create|modify|delete (alias: new = create)."
529
+ "allowed: create|modify|delete|non_code (alias: new = create)."
530
+ )
531
+ # R16: Change Type and Files must agree — file-op types need a real
532
+ # path; non_code is the only legal shape for no-file tasks.
533
+ files_body = _plan_task_field_body(body, "Files")
534
+ if files_body.strip():
535
+ file_paths = _plan_task_file_paths(files_body)
536
+ if change_type in _FILE_CHANGE_TYPES and not file_paths:
537
+ issues.append(
538
+ f"{label} has 'Change Type: {raw_change_type}' but 'Files:' lists "
539
+ "no real file path; use 'Change Type: non_code' for tasks that "
540
+ "touch no files."
541
+ )
542
+ elif change_type == "non_code" and file_paths:
543
+ issues.append(
544
+ f"{label} has 'Change Type: non_code' but 'Files:' lists a file path; "
545
+ "use create|modify|delete for file-touching tasks."
546
+ )
547
+ raw_tdd = _task_tdd_applicable(body)
548
+ if raw_tdd and raw_tdd.lower() not in _TDD_APPLICABLE_VALUES:
549
+ issues.append(
550
+ f"{label} has invalid 'TDD Applicable: {raw_tdd}'; allowed: yes|no."
530
551
  )
531
552
  if numbers:
532
553
  if len(set(numbers)) != len(numbers):
@@ -575,8 +596,7 @@ def _plan_tasks_missing_code(content: str) -> bool:
575
596
  return False
576
597
  for body in bodies:
577
598
  skeleton = _plan_task_field_body(body, "Skeleton")
578
- tdd_applicable = _plan_task_field_value(body, "TDD Applicable")
579
- if tdd_applicable.lower() == "yes" and not _has_complete_code_fence(skeleton):
599
+ if _task_tdd_applicable(body).lower() == "yes" and not _has_complete_code_fence(skeleton):
580
600
  return True
581
601
  return False
582
602
 
@@ -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
@@ -1 +1 @@
1
- R2P_VERSION = "0.4.0"
1
+ R2P_VERSION = "0.4.2"