@xenonbyte/req-2-plan 0.2.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/README.zh-CN.md +158 -0
  4. package/bin/r2p.js +38 -0
  5. package/docs/req-to-plan-design.md +277 -0
  6. package/package.json +47 -0
  7. package/requirements.txt +1 -0
  8. package/tools/r2p +10 -0
  9. package/tools/r2p-continue +10 -0
  10. package/tools/r2p-gap-open +10 -0
  11. package/tools/r2p-gap-resolve +10 -0
  12. package/tools/r2p-reopen +10 -0
  13. package/tools/r2p-start +10 -0
  14. package/tools/r2p-status +10 -0
  15. package/tools/r2p-switch +10 -0
  16. package/tools/r2p-tier-lock +10 -0
  17. package/tools/workflow_cli/__init__.py +0 -0
  18. package/tools/workflow_cli/__main__.py +5 -0
  19. package/tools/workflow_cli/agent_shortcuts.py +778 -0
  20. package/tools/workflow_cli/agent_templates/claude/SKILL.md +34 -0
  21. package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +16 -0
  22. package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-open.md +8 -0
  23. package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-resolve.md +8 -0
  24. package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +8 -0
  25. package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +10 -0
  26. package/tools/workflow_cli/agent_templates/claude/commands/r2p-status.md +8 -0
  27. package/tools/workflow_cli/agent_templates/claude/commands/r2p-switch.md +8 -0
  28. package/tools/workflow_cli/agent_templates/claude/commands/r2p-tier-lock.md +8 -0
  29. package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +12 -0
  30. package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-open/SKILL.md +12 -0
  31. package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-resolve/SKILL.md +12 -0
  32. package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +12 -0
  33. package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +14 -0
  34. package/tools/workflow_cli/agent_templates/codex/skills/r2p-status/SKILL.md +12 -0
  35. package/tools/workflow_cli/agent_templates/codex/skills/r2p-switch/SKILL.md +12 -0
  36. package/tools/workflow_cli/agent_templates/codex/skills/r2p-tier-lock/SKILL.md +12 -0
  37. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +4 -0
  38. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-open.toml +4 -0
  39. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-resolve.toml +4 -0
  40. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +4 -0
  41. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +4 -0
  42. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-status.toml +4 -0
  43. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-switch.toml +4 -0
  44. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-tier-lock.toml +4 -0
  45. package/tools/workflow_cli/artifact.py +228 -0
  46. package/tools/workflow_cli/cli.py +1779 -0
  47. package/tools/workflow_cli/gates.py +471 -0
  48. package/tools/workflow_cli/install.py +900 -0
  49. package/tools/workflow_cli/install_cli.py +158 -0
  50. package/tools/workflow_cli/link_expander.py +102 -0
  51. package/tools/workflow_cli/models.py +504 -0
  52. package/tools/workflow_cli/output.py +91 -0
  53. package/tools/workflow_cli/repo_baseline.py +137 -0
  54. package/tools/workflow_cli/state.py +621 -0
  55. package/tools/workflow_cli/tier.py +201 -0
  56. package/tools/workflow_cli/tier_keywords.yaml +45 -0
  57. package/tools/workflow_cli/version.py +1 -0
@@ -0,0 +1,471 @@
1
+ """
2
+ Tier-aware structured gate checks for the req-to-plan workflow CLI.
3
+
4
+ The CLI runs these structural checks before checkpoint approval.
5
+ The Agent handles semantic quality; this module handles structural validation.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from tools.workflow_cli.models import (
14
+ BundleAuthorization,
15
+ CheckpointRecord,
16
+ STAGE_ARTIFACT_MAP,
17
+ STAGE_REQUIRED_UPSTREAM_CHECKPOINTS,
18
+ Stage,
19
+ TierEstimate,
20
+ TierModifier,
21
+ )
22
+ from tools.workflow_cli.output import EXIT_GATE_FAIL
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # GateResult
26
+ # ---------------------------------------------------------------------------
27
+
28
+ @dataclass
29
+ class GateResult:
30
+ passed: bool
31
+ issues: list[str]
32
+ exit_code: int = 0 # 0=pass, 2=entry failure, 3=quality failure, 5=forced-subagent-review-required
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Entry Gate
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def check_entry_gate(
40
+ run_dir: Path,
41
+ stage: Stage,
42
+ approved_checkpoints: list, # list[CheckpointRecord]
43
+ bundle_authorizations: list, # list[BundleAuthorization]
44
+ ) -> GateResult:
45
+ """Check entry gate: all required upstream artifacts are approved and present."""
46
+ issues: list[str] = []
47
+ required_stages = STAGE_REQUIRED_UPSTREAM_CHECKPOINTS.get(stage, [])
48
+
49
+ # Build set of stages covered by approved checkpoints
50
+ approved_stages: set[Stage] = {cp.stage for cp in approved_checkpoints}
51
+
52
+ for upstream_stage in required_stages:
53
+ # Check 1: is there an approval or live bundle covering this stage?
54
+ covered = upstream_stage in approved_stages or any(
55
+ ba.covers(upstream_stage) for ba in bundle_authorizations
56
+ )
57
+ if not covered:
58
+ issues.append(
59
+ f"Missing approval for upstream stage {upstream_stage.value!r}: "
60
+ "no approved checkpoint and no live bundle authorization."
61
+ )
62
+ continue # Skip file-existence check when no authorization exists
63
+
64
+ # Check 2: the artifact file must exist on disk
65
+ artifact_filename = STAGE_ARTIFACT_MAP.get(upstream_stage)
66
+ if artifact_filename:
67
+ artifact_path = run_dir / artifact_filename
68
+ if not artifact_path.exists():
69
+ issues.append(
70
+ f"Upstream artifact file missing for stage {upstream_stage.value!r}: "
71
+ f"{artifact_filename}"
72
+ )
73
+
74
+ return GateResult(
75
+ passed=len(issues) == 0,
76
+ issues=issues,
77
+ exit_code=EXIT_GATE_FAIL if issues else 0,
78
+ )
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Upstream ID reference pattern
83
+ # ---------------------------------------------------------------------------
84
+
85
+ # IDs that represent upstream references: REQ-*, RISK-*, DES-*, SPEC-*
86
+ _UPSTREAM_ID_PATTERN = re.compile(
87
+ r"\b(REQ-[A-Z]+-\d+|RISK-[A-Z]+-\d+|DES-[A-Z]+-\d+|SPEC-[A-Z]+-\d+)\b"
88
+ )
89
+
90
+ # Closure status tags
91
+ _CLOSURE_TAGS = frozenset(["[ADDRESSED]", "[DEFERRED]", "[N/A]", "[OUT-OF-SCOPE]", "[CLOSED]"])
92
+
93
+ # IDs of form [A-Z]+-[A-Z]+-[0-9]+ (definition context: heading or line-start)
94
+ _DEFINED_ID_PATTERN = re.compile(r"\b([A-Z]+-[A-Z]+-\d+)\b")
95
+
96
+
97
+ def _find_defined_ids(content: str) -> set[str]:
98
+ """Return IDs that are defined in headings (i.e. the current artifact is defining them)."""
99
+ heading_pattern = re.compile(r"^#{1,6}\s+.*\b([A-Z]+-[A-Z]+-\d+)\b", re.MULTILINE)
100
+ return set(heading_pattern.findall(content))
101
+
102
+
103
+ def _find_ids_without_closure(content: str) -> list[str]:
104
+ """Return upstream IDs referenced in content that have no closure tag.
105
+
106
+ IDs defined in headings of the current artifact are excluded — they are
107
+ being *defined* here, not referencing upstream artifacts that need closure.
108
+ """
109
+ all_refs = set(_UPSTREAM_ID_PATTERN.findall(content))
110
+ defined_here = _find_defined_ids(content)
111
+ # Only check IDs that are referenced but NOT defined in this artifact
112
+ refs_to_check = all_refs - defined_here
113
+ unclosed: list[str] = []
114
+ for ref_id in sorted(refs_to_check):
115
+ # Look for the ID followed (anywhere on the same token-group) by a closure tag
116
+ # We search for patterns like: ID [TAG] anywhere in content
117
+ has_closure = False
118
+ for tag in _CLOSURE_TAGS:
119
+ # Allow flexible spacing between ID and tag on the same general vicinity
120
+ pattern = re.compile(
121
+ re.escape(ref_id) + r"[^\n]*" + re.escape(tag),
122
+ re.IGNORECASE,
123
+ )
124
+ if pattern.search(content):
125
+ has_closure = True
126
+ break
127
+ if not has_closure:
128
+ unclosed.append(ref_id)
129
+ return unclosed
130
+
131
+
132
+ def _find_duplicate_ids(content: str) -> list[str]:
133
+ """Return IDs that appear more than once in heading (definition) context."""
134
+ heading_pattern = re.compile(r"^#{1,6}\s+.*\b([A-Z]+-[A-Z]+-\d+)\b", re.MULTILINE)
135
+ heading_ids = heading_pattern.findall(content)
136
+
137
+ from collections import Counter
138
+ counts = Counter(heading_ids)
139
+ return [id_ for id_, count in counts.items() if count > 1]
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # SPEC External Documentation Checked helpers
144
+ # ---------------------------------------------------------------------------
145
+
146
+ _EXTERNAL_DOCS_RE = re.compile(r"^## External Documentation Checked\s*$", re.MULTILINE)
147
+ _H2_RE = re.compile(r"^##\s+", re.MULTILINE)
148
+ _MARKDOWN_CODE_FENCE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})", re.MULTILINE)
149
+ _MARKDOWN_FENCE_MARKER_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})")
150
+ _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
151
+
152
+
153
+ def _unfenced_markdown_lines(content: str):
154
+ """Yield (line, start, end) for lines outside Markdown fenced code blocks."""
155
+ fence_char = ""
156
+ fence_len = 0
157
+ offset = 0
158
+ for line in content.splitlines(keepends=True):
159
+ marker = _MARKDOWN_FENCE_MARKER_RE.match(line)
160
+ if fence_char:
161
+ if (
162
+ marker
163
+ and marker.group(1)[0] == fence_char
164
+ and len(marker.group(1)) >= fence_len
165
+ and not line[marker.end():].strip()
166
+ ):
167
+ fence_char = ""
168
+ fence_len = 0
169
+ offset += len(line)
170
+ continue
171
+
172
+ if marker:
173
+ fence_char = marker.group(1)[0]
174
+ fence_len = len(marker.group(1))
175
+ offset += len(line)
176
+ continue
177
+
178
+ start = offset
179
+ offset += len(line)
180
+ yield line, start, offset
181
+
182
+
183
+ def _plain_table_cell(cell: str) -> str:
184
+ return cell.strip().strip("_*`").strip()
185
+
186
+
187
+ def _is_external_docs_inventory_row(line: str) -> bool:
188
+ if line == "N/A — no external dependencies":
189
+ return True
190
+ if not (line.startswith("|") and line.endswith("|")):
191
+ return False
192
+ cells = [cell.strip() for cell in line.strip("|").split("|")]
193
+ if len(cells) != 4:
194
+ return False
195
+ if [cell.lower() for cell in cells] == ["dependency", "version", "check date", "conclusion"]:
196
+ return False
197
+ if all(set(cell) <= {"-", ":", " "} for cell in cells):
198
+ return False
199
+ if not all(cells):
200
+ return False
201
+
202
+ dependency, version, check_date, conclusion = [_plain_table_cell(cell) for cell in cells]
203
+ normalized = [cell.lower() for cell in (dependency, version, check_date, conclusion)]
204
+ if normalized == ["example", "x.y", "yyyy-mm-dd", "context7 checked / unconfirmed"]:
205
+ return False
206
+ if dependency.lower() == "example":
207
+ return False
208
+ if version.lower() == "x.y":
209
+ return False
210
+ if check_date.lower() == "yyyy-mm-dd":
211
+ return False
212
+ if conclusion.lower() == "context7 checked / unconfirmed":
213
+ return False
214
+ if not _ISO_DATE_RE.fullmatch(check_date):
215
+ return False
216
+ return bool(dependency and version and conclusion)
217
+
218
+
219
+ def _has_external_docs_inventory(content: str) -> bool:
220
+ section_start = None
221
+ for line, _, end in _unfenced_markdown_lines(content):
222
+ if _EXTERNAL_DOCS_RE.match(line):
223
+ section_start = end
224
+ break
225
+ if section_start is None:
226
+ return False
227
+
228
+ for line, start, _ in _unfenced_markdown_lines(content):
229
+ if start < section_start:
230
+ continue
231
+ if _H2_RE.match(line):
232
+ break
233
+ stripped = line.strip()
234
+ if stripped and _is_external_docs_inventory_row(stripped):
235
+ return True
236
+ return False
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # PLAN code-block gate helpers
241
+ # ---------------------------------------------------------------------------
242
+
243
+ _PLAN_TASK_RE = re.compile(r"^### PLAN-TASK-\d+", re.MULTILINE)
244
+ _CODE_FENCE_LINE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(.*)$")
245
+ _PLAN_TASK_FIELD_RE = re.compile(
246
+ r"^(Spec References|Change Type|TDD Applicable|Files|Skeleton|Steps|Verification):"
247
+ )
248
+
249
+
250
+ def _plan_task_starts(content: str) -> list[int]:
251
+ return [
252
+ start
253
+ for line, start, _ in _unfenced_markdown_lines(content)
254
+ if _PLAN_TASK_RE.match(line)
255
+ ]
256
+
257
+
258
+ def _plan_task_field_body(task_body: str, field: str) -> str:
259
+ found = _find_plan_task_field(task_body, field)
260
+ if found is None:
261
+ return ""
262
+ match, line_start = found
263
+ body_start = line_start + match.end()
264
+ next_start = _find_next_plan_task_field_start(task_body, body_start)
265
+ end = next_start if next_start is not None else len(task_body)
266
+ return f"{match.group(1)}\n{task_body[body_start:end]}"
267
+
268
+
269
+ def _find_plan_task_field(task_body: str, field: str):
270
+ field_re = re.compile(rf"^{re.escape(field)}:[ \t]*(.*)$")
271
+ for line, start, _ in _unfenced_markdown_lines(task_body):
272
+ match = field_re.match(line)
273
+ if match:
274
+ return match, start
275
+ return None
276
+
277
+
278
+ def _find_next_plan_task_field_start(task_body: str, after: int) -> int | None:
279
+ for line, start, _ in _unfenced_markdown_lines(task_body):
280
+ if start >= after and _PLAN_TASK_FIELD_RE.match(line):
281
+ return start
282
+ return None
283
+
284
+
285
+ def _plan_task_field_value(task_body: str, field: str) -> str:
286
+ found = _find_plan_task_field(task_body, field)
287
+ if found is None:
288
+ return ""
289
+ match, _ = found
290
+ return match.group(1).strip()
291
+
292
+
293
+ def _has_complete_code_fence(content: str) -> bool:
294
+ fence_char = ""
295
+ fence_len = 0
296
+ has_body = False
297
+
298
+ for line in content.splitlines():
299
+ marker = _CODE_FENCE_LINE_RE.match(line)
300
+ if fence_char:
301
+ if (
302
+ marker
303
+ and marker.group(1)[0] == fence_char
304
+ and len(marker.group(1)) >= fence_len
305
+ and not marker.group(2).strip()
306
+ ):
307
+ if has_body:
308
+ return True
309
+ fence_char = ""
310
+ fence_len = 0
311
+ has_body = False
312
+ continue
313
+ if line.strip():
314
+ has_body = True
315
+ continue
316
+
317
+ if marker and marker.group(2).strip():
318
+ fence_char = marker.group(1)[0]
319
+ fence_len = len(marker.group(1))
320
+ has_body = False
321
+
322
+ return False
323
+
324
+
325
+ def _plan_tasks_missing_code(content: str) -> bool:
326
+ """True if any TDD-applicable PLAN-TASK has no fenced code block in its Skeleton field."""
327
+ starts = _plan_task_starts(content)
328
+ if not starts:
329
+ return False
330
+ bounds = starts + [len(content)]
331
+ for i in range(len(starts)):
332
+ body = content[bounds[i]:bounds[i + 1]]
333
+ skeleton = _plan_task_field_body(body, "Skeleton")
334
+ tdd_applicable = _plan_task_field_value(body, "TDD Applicable")
335
+ if tdd_applicable.lower() == "yes" and not _has_complete_code_fence(skeleton):
336
+ return True
337
+ return False
338
+
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # Quality Gate
342
+ # ---------------------------------------------------------------------------
343
+
344
+ def check_quality_gate(
345
+ run_dir: Path,
346
+ stage: Stage,
347
+ tier: TierEstimate | None,
348
+ approved_checkpoints: list, # list[CheckpointRecord]
349
+ artifact_content: str,
350
+ ) -> GateResult:
351
+ """Check quality gate: tier-aware structural content checks."""
352
+ # Check 1: tier must be locked
353
+ if tier is None:
354
+ return GateResult(
355
+ passed=False,
356
+ issues=["Tier not locked; run tier-lock before quality gate"],
357
+ exit_code=3,
358
+ )
359
+
360
+ issues: list[str] = []
361
+
362
+ # Check 2: content must be non-empty
363
+ if not artifact_content or not artifact_content.strip():
364
+ issues.append("Artifact content is empty or whitespace-only.")
365
+
366
+ if not issues:
367
+ # Check 3: upstream reference coverage closure (all tiers)
368
+ unclosed = _find_ids_without_closure(artifact_content)
369
+ for ref_id in unclosed:
370
+ issues.append(
371
+ f"Upstream reference {ref_id!r} appears in artifact but has no closure status tag "
372
+ f"([ADDRESSED], [DEFERRED], [N/A], [OUT-OF-SCOPE], or [CLOSED])."
373
+ )
374
+
375
+ # Check 4: ID uniqueness within artifact
376
+ duplicates = _find_duplicate_ids(artifact_content)
377
+ for dup_id in duplicates:
378
+ issues.append(
379
+ f"Duplicate ID definition {dup_id!r} found in artifact; each ID must be unique."
380
+ )
381
+
382
+ # Check 5 (PLAN, standard tier): TDD-applicable tasks must carry a code block.
383
+ from tools.workflow_cli.models import TierBase
384
+ if stage == Stage.PLAN and tier.base == TierBase.STANDARD:
385
+ if not _plan_task_starts(artifact_content):
386
+ issues.append(
387
+ "PLAN is missing '### PLAN-TASK-*' sections; standard tier requires "
388
+ "machine-parseable executable anchors."
389
+ )
390
+ elif _plan_tasks_missing_code(artifact_content):
391
+ issues.append(
392
+ "PLAN has a 'TDD Applicable: yes' task with no fenced code block; "
393
+ "add a Skeleton code block (standard tier requires executable anchors)."
394
+ )
395
+
396
+ # Check 6 (SPEC): the External Documentation Checked section must be present and non-empty.
397
+ if stage == Stage.SPEC:
398
+ if not _has_external_docs_inventory(artifact_content):
399
+ issues.append(
400
+ "SPEC is missing a non-empty '## External Documentation Checked' section. "
401
+ "Add it; if there are no external dependencies, include an explicit "
402
+ "'N/A — no external dependencies' row."
403
+ )
404
+
405
+ return GateResult(
406
+ passed=len(issues) == 0,
407
+ issues=issues,
408
+ exit_code=3 if issues else 0,
409
+ )
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # Forced Subagent Review Check
414
+ # ---------------------------------------------------------------------------
415
+
416
+ _FORCED_REVIEW_MODIFIERS: frozenset[TierModifier] = frozenset({
417
+ TierModifier.MIGRATION,
418
+ TierModifier.SAFETY,
419
+ TierModifier.CROSS_PROJECT,
420
+ })
421
+
422
+ _FORCED_REVIEW_STAGES: frozenset[Stage] = frozenset({
423
+ Stage.DESIGN,
424
+ Stage.SPEC,
425
+ Stage.PLAN,
426
+ })
427
+
428
+
429
+ def check_forced_subagent_review(
430
+ stage: Stage,
431
+ tier: TierEstimate | None,
432
+ reviews_dir: Path,
433
+ version: int = 1,
434
+ ) -> GateResult:
435
+ """
436
+ Refuse with exit_code=5 when ALL conditions hold:
437
+ - tier is not None
438
+ - tier.modifiers intersects {MIGRATION, SAFETY, CROSS_PROJECT}
439
+ - stage is in {DESIGN, SPEC, PLAN}
440
+ - no version-matched subagent review file exists under reviews_dir for this stage
441
+ """
442
+ # Condition 1: tier must exist
443
+ if tier is None:
444
+ return GateResult(passed=True, issues=[], exit_code=0)
445
+
446
+ # Condition 2: modifiers must intersect forced-review set
447
+ if not (tier.modifiers & _FORCED_REVIEW_MODIFIERS):
448
+ return GateResult(passed=True, issues=[], exit_code=0)
449
+
450
+ # Condition 3: stage must be in forced-review stages
451
+ if stage not in _FORCED_REVIEW_STAGES:
452
+ return GateResult(passed=True, issues=[], exit_code=0)
453
+
454
+ # Condition 4: a real, version-matched subagent review must exist.
455
+ stage_name = stage.value
456
+ subagent_file = reviews_dir / f"{stage_name}-subagent-review-v{version}.md"
457
+ if subagent_file.exists():
458
+ return GateResult(passed=True, issues=[], exit_code=0)
459
+
460
+ # All conditions met: require forced subagent review
461
+ triggering = sorted(m.value for m in tier.modifiers & _FORCED_REVIEW_MODIFIERS)
462
+ return GateResult(
463
+ passed=False,
464
+ issues=[
465
+ f"Forced subagent review required for stage {stage.value!r} "
466
+ f"with modifier(s) {triggering!r}. "
467
+ f"Run a review subagent and write its findings to {subagent_file} "
468
+ f"(the filename must match {subagent_file.name!r}), then retry checkpoint approval."
469
+ ],
470
+ exit_code=5,
471
+ )