@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,778 @@
1
+ """
2
+ Agent shortcut dispatcher for r2p-* commands.
3
+
4
+ Usage:
5
+ python3 -m tools.workflow_cli.agent_shortcuts <subcommand> [flags]
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import re
11
+ import shutil
12
+ import shlex
13
+ import sys
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ from tools.workflow_cli.models import RunStatus, WorkId
18
+ from tools.workflow_cli.output import EXIT_CONFLICT
19
+
20
+ ACTIVE_POINTER_FILE = ".workflow-active"
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Active pointer helpers
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ def _pointer_path(base_path: Path) -> Path:
29
+ return base_path / ".req-to-plan" / ACTIVE_POINTER_FILE
30
+
31
+
32
+ def read_active_pointer(base_path: Path) -> dict | None:
33
+ path = _pointer_path(base_path)
34
+ if not path.exists():
35
+ return None
36
+ data: dict[str, str] = {}
37
+ for line in path.read_text(encoding="utf-8").splitlines():
38
+ if ": " in line:
39
+ k, _, v = line.partition(": ")
40
+ data[k.strip()] = v.strip()
41
+ return data if data else None
42
+
43
+
44
+ def write_active_pointer(base_path: Path, work_id: str, reason: str = "workflow_start") -> None:
45
+ work_id = _validate_work_id(work_id)
46
+ path = _pointer_path(base_path)
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ run_rel = f".req-to-plan/{work_id}/run.md"
49
+ updated_at = datetime.now(timezone.utc).astimezone().isoformat()
50
+ content = (
51
+ f"selected_work_id: {work_id}\n"
52
+ f"selected_run: {run_rel}\n"
53
+ f"updated_at: {updated_at}\n"
54
+ f"reason: {reason}\n"
55
+ )
56
+ path.write_text(content, encoding="utf-8")
57
+
58
+
59
+ def _validate_work_id(raw: str) -> str:
60
+ try:
61
+ return str(WorkId(raw))
62
+ except ValueError as exc:
63
+ print(
64
+ "blocked: invalid_work_id\n"
65
+ f"work_id: {raw}\n"
66
+ f"reason: {exc}\n"
67
+ )
68
+ sys.exit(2)
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Open run scanner
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def scan_open_runs(base_path: Path) -> list[str]:
77
+ r2p_dir = base_path / ".req-to-plan"
78
+ if not r2p_dir.exists():
79
+ return []
80
+ open_ids: list[str] = []
81
+ for run_md in sorted(r2p_dir.glob("*/run.md")):
82
+ try:
83
+ from tools.workflow_cli.state import RunStateManager
84
+ mgr = RunStateManager(run_md.parent)
85
+ record = mgr.load()
86
+ if not is_terminal(record.status):
87
+ open_ids.append(run_md.parent.name)
88
+ except Exception as e:
89
+ print(f"warning: could not load run {run_md.parent.name!r}: {e}", file=sys.stderr)
90
+ return open_ids
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Work ID generation
95
+ # ---------------------------------------------------------------------------
96
+
97
+ _STOP_WORDS = frozenset(
98
+ {
99
+ "a", "an", "the", "and", "or", "but", "for", "of", "in", "on", "to",
100
+ "at", "by", "with", "from", "as", "is", "it", "be", "are", "was",
101
+ "were", "that", "this", "we", "our", "should", "will", "can", "do",
102
+ }
103
+ )
104
+
105
+
106
+ def generate_work_id(
107
+ requirement: str,
108
+ base_path: Path | None = None,
109
+ today: str | None = None,
110
+ ) -> str:
111
+ date_str = today or datetime.now().strftime("%Y%m%d")
112
+ prefix = f"WF-{date_str}-"
113
+ max_slug_len = 48 - len(prefix)
114
+
115
+ slug = re.sub(r"[^a-zA-Z0-9\s]", " ", requirement).lower().strip()
116
+ words = [w for w in slug.split() if w not in _STOP_WORDS and len(w) > 1]
117
+ if not words:
118
+ words = [w for w in slug.split() if w]
119
+ if not words:
120
+ import hashlib
121
+ h = hashlib.md5(requirement.encode()).hexdigest()[:8]
122
+ words = [f"run-{h}"]
123
+
124
+ candidate = "-".join(words[:5])
125
+ candidate = re.sub(r"-+", "-", candidate).strip("-")[:max_slug_len]
126
+ if len(candidate) < 2:
127
+ import hashlib
128
+ h = hashlib.md5(requirement.encode()).hexdigest()[:8]
129
+ candidate = f"run-{h}"
130
+
131
+ base_id = f"{prefix}{candidate}"
132
+
133
+ if base_path is None:
134
+ return base_id
135
+
136
+ if not (base_path / ".req-to-plan" / base_id).exists():
137
+ return base_id
138
+
139
+ for n in range(2, 100):
140
+ suffix = f"-{n}"
141
+ alt = f"{prefix}{candidate[:max_slug_len - len(suffix)]}{suffix}"
142
+ if not (base_path / ".req-to-plan" / alt).exists():
143
+ return alt
144
+
145
+ raise RuntimeError(
146
+ f"Could not generate a unique work ID for {base_id!r} after 98 attempts. "
147
+ "Clean up old runs in .req-to-plan/ before starting a new one."
148
+ )
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Terminal check
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ def is_terminal(status: RunStatus) -> bool:
157
+ return status == RunStatus.CLOSED_AT_PLAN_CHECKPOINT
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Internal CLI runner
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ def _run_cli(args_list: list[str], base_path: Path) -> int:
166
+ from tools.workflow_cli.cli import main as cli_main
167
+ try:
168
+ cli_main(["--base-path", str(base_path)] + args_list)
169
+ return 0
170
+ except SystemExit as exc:
171
+ code = exc.code
172
+ return int(code) if isinstance(code, int) else (1 if code else 0)
173
+
174
+
175
+ def _shell_join(parts: list[str | Path]) -> str:
176
+ return " ".join(shlex.quote(str(part)) for part in parts)
177
+
178
+
179
+ def _repo_root() -> Path:
180
+ return Path(__file__).resolve().parents[2]
181
+
182
+
183
+ def _python_executable() -> str:
184
+ if sys.executable:
185
+ return sys.executable
186
+ return "python3" if shutil.which("python3") else "python"
187
+
188
+
189
+ def _workflow_cli_command(base_path: Path, args_list: list[str]) -> str:
190
+ return _shell_join(
191
+ [
192
+ "env",
193
+ f"PYTHONPATH={_repo_root()}",
194
+ _python_executable(),
195
+ "-m",
196
+ "tools.workflow_cli",
197
+ "--base-path",
198
+ base_path,
199
+ *args_list,
200
+ ]
201
+ )
202
+
203
+
204
+ def _tier_lock_command(base_path: Path, work_id: str, record) -> str:
205
+ estimate = getattr(record, "tier_estimate", None)
206
+ base = estimate.base.value if estimate is not None else "light"
207
+ modifiers = (
208
+ sorted(m.value for m in estimate.modifiers)
209
+ if estimate is not None
210
+ else []
211
+ )
212
+ args = ["tier-lock", "--work-id", work_id, "--base", base]
213
+ if modifiers:
214
+ args.extend(["--modifiers", ",".join(modifiers)])
215
+ args.append("--confirm")
216
+ return _workflow_cli_command(base_path, args)
217
+
218
+
219
+ def _prepare_input_file(run_dir: Path, stage: str, suffix: str, seed: str = "") -> Path:
220
+ path = run_dir / "inputs" / f"{stage}-{suffix}.md"
221
+ path.parent.mkdir(parents=True, exist_ok=True)
222
+ if not path.exists():
223
+ path.write_text(seed, encoding="utf-8")
224
+ return path
225
+
226
+
227
+ def _stage_content_command(
228
+ base_path: Path,
229
+ work_id: str,
230
+ stage: str,
231
+ command: str,
232
+ content_file: Path,
233
+ ) -> str:
234
+ return _workflow_cli_command(
235
+ base_path,
236
+ [
237
+ command,
238
+ "--work-id", work_id,
239
+ "--stage", stage,
240
+ "--content-file", content_file,
241
+ ],
242
+ )
243
+
244
+
245
+ def _emit_checkpoint_stop(
246
+ base_path: Path,
247
+ work_id: str,
248
+ stage: str,
249
+ record,
250
+ run_dir: Path,
251
+ ) -> None:
252
+ """Print the correct checkpoint stop for the current run.
253
+
254
+ Forced-review runs (a migration/safety/cross_project modifier at
255
+ DESIGN/SPEC/PLAN) that lack a version-matched subagent review file stop with
256
+ ``needs_subagent_review``: the agent is authorized to run a read-only review
257
+ subagent autonomously and write its findings to the printed ``review_file``,
258
+ with no separate human authorization. Every other checkpoint stops with
259
+ ``needs_human_approval`` for an explicit ``checkpoint-decide``.
260
+ """
261
+ from tools.workflow_cli.gates import check_forced_subagent_review
262
+ from tools.workflow_cli.state import get_active_artifact
263
+
264
+ aa = get_active_artifact(record, record.current_stage)
265
+ version = aa.version if aa is not None else 1
266
+ reviews_dir = run_dir / "reviews"
267
+ review_result = check_forced_subagent_review(
268
+ record.current_stage, record.tier_locked, reviews_dir, version
269
+ )
270
+ if not review_result.passed:
271
+ review_file = reviews_dir / f"{stage}-subagent-review-v{version}.md"
272
+ modifiers = (
273
+ ", ".join(sorted(m.value for m in record.tier_locked.modifiers))
274
+ if record.tier_locked is not None
275
+ else ""
276
+ )
277
+ print(
278
+ "stop: needs_subagent_review\n"
279
+ f"stage: {stage}\n"
280
+ f"review_file: {review_file}\n"
281
+ f"reason: forced subagent review required (tier modifier: {modifiers})\n"
282
+ "note: you are authorized to spawn a read-only review subagent now; "
283
+ "separate human approval is NOT required for this step\n"
284
+ "next: have the review subagent audit the stage artifact, write its "
285
+ "findings to review_file, then r2p-continue\n"
286
+ )
287
+ return
288
+
289
+ approve_cmd = _workflow_cli_command(
290
+ base_path,
291
+ ["checkpoint-decide", "--work-id", work_id, "--stage", stage,
292
+ "--decision", "approved", "--confirm"],
293
+ )
294
+ changes_cmd = _workflow_cli_command(
295
+ base_path,
296
+ ["checkpoint-decide", "--work-id", work_id, "--stage", stage,
297
+ "--decision", "changes_requested"],
298
+ )
299
+ print(
300
+ f"stop: needs_human_approval\nstage: {stage}\n"
301
+ "next: "
302
+ f"{approve_cmd}\n"
303
+ "alt: "
304
+ f"{changes_cmd}\n"
305
+ )
306
+
307
+
308
+ def _open_owner_route(record):
309
+ return next(
310
+ (
311
+ route
312
+ for route in record.open_routes
313
+ if route.status == "open" and route.owner_stage == record.current_stage
314
+ ),
315
+ None,
316
+ )
317
+
318
+
319
+ def _emit_gap_resolve_stop(base_path: Path, work_id: str, stage: str, route_id: str) -> None:
320
+ resolve_cmd = _workflow_cli_command(
321
+ base_path,
322
+ ["gap-resolve", "--work-id", work_id, "--route-id", route_id],
323
+ )
324
+ print(
325
+ "stop: needs_gap_resolve\n"
326
+ f"stage: {stage}\n"
327
+ f"route_id: {route_id}\n"
328
+ f"next: {resolve_cmd}\n"
329
+ )
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # Subcommand handlers
334
+ # ---------------------------------------------------------------------------
335
+
336
+
337
+ def _resolve_start_requirement(ns: argparse.Namespace) -> tuple[str, Path | None]:
338
+ """Resolve the start requirement from either --file or the positional arg.
339
+
340
+ Returns (requirement_text, file_path); file_path is None for inline text.
341
+ Exits with a structured ``blocked:`` message (exit 2) on bad input.
342
+ """
343
+ file_arg = getattr(ns, "file", None)
344
+ raw = ns.requirement
345
+
346
+ if file_arg and raw:
347
+ print(
348
+ "blocked: ambiguous_requirement\n"
349
+ "next: pass either a positional requirement or --file, not both\n"
350
+ )
351
+ sys.exit(2)
352
+
353
+ if file_arg:
354
+ file_path = Path(file_arg)
355
+ if not file_path.is_file():
356
+ print(f"blocked: requirement_file_not_found\nfile: {file_arg}\n")
357
+ sys.exit(2)
358
+ text = file_path.read_text(encoding="utf-8")
359
+ if not text.strip():
360
+ print(f"blocked: empty_requirement_file\nfile: {file_arg}\n")
361
+ sys.exit(2)
362
+ return text, file_path.resolve()
363
+
364
+ if not raw or not raw.strip():
365
+ print("blocked: missing_requirement\nnext: r2p-start \"<raw requirement>\"\n")
366
+ sys.exit(2)
367
+ return raw, None
368
+
369
+
370
+ def _cmd_start(ns: argparse.Namespace, base_path: Path) -> None:
371
+ requirement, file_path = _resolve_start_requirement(ns)
372
+
373
+ pointer = read_active_pointer(base_path)
374
+ open_runs = scan_open_runs(base_path)
375
+
376
+ if not ns.separate:
377
+ if pointer and pointer.get("selected_work_id") in open_runs:
378
+ active_id = pointer["selected_work_id"]
379
+ print(f"blocked: active_run_exists\nactive_run: {active_id}\nnext: r2p-continue\n")
380
+ sys.exit(1)
381
+ if len(open_runs) == 1:
382
+ print(f"blocked: open_run_exists\nopen_run: {open_runs[0]}\nnext: r2p-switch --work-id {open_runs[0]}\n")
383
+ sys.exit(1)
384
+ if len(open_runs) > 1:
385
+ ids = ", ".join(open_runs)
386
+ print(f"blocked: open_runs_exist\nopen_runs: {ids}\nnext: r2p-switch --work-id <id>\n")
387
+ sys.exit(1)
388
+
389
+ 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]
394
+ exit_code = _run_cli(run_args, base_path)
395
+ if exit_code != 0:
396
+ sys.exit(exit_code)
397
+
398
+ write_active_pointer(base_path, work_id, reason="workflow_start")
399
+ print(
400
+ f"created: .req-to-plan/{work_id}/run.md\n"
401
+ f"selected_run: .req-to-plan/{work_id}/run.md\n"
402
+ f"next: r2p-continue\n"
403
+ )
404
+
405
+
406
+ def _cmd_continue(ns: argparse.Namespace, base_path: Path) -> None:
407
+ pointer = read_active_pointer(base_path)
408
+ if not pointer:
409
+ print("no_selected_run: true\nnext: r2p-status --all\n")
410
+ sys.exit(1)
411
+ work_id = _validate_work_id(pointer["selected_work_id"])
412
+ run_path = base_path / ".req-to-plan" / work_id / "run.md"
413
+ if not run_path.exists():
414
+ print(f"blocked: source_run_not_found\nwork_id: {work_id}\n")
415
+ sys.exit(7)
416
+
417
+ from tools.workflow_cli.artifact import read_artifact
418
+ from tools.workflow_cli.state import RunStateManager, get_active_artifact
419
+ from tools.workflow_cli.models import RunStatus, Stage
420
+ manager = RunStateManager(run_path.parent)
421
+
422
+ while True:
423
+ record = manager.load()
424
+ s = record.status
425
+ stage = record.current_stage.value
426
+
427
+ if s == RunStatus.CLOSED_AT_PLAN_CHECKPOINT:
428
+ print(f"done: run_closed\nwork_id: {work_id}\nplan: 07-plan.md\n"
429
+ "next: hand the PLAN to your executor\n")
430
+ sys.exit(0)
431
+
432
+ if s == RunStatus.ACTIVE_STAGE_DRAFT:
433
+ if record.tier_locked is None:
434
+ print(f"stop: tier_not_locked\nstage: {stage}\n"
435
+ f"next: {_tier_lock_command(base_path, work_id, record)}\n")
436
+ sys.exit(0)
437
+ aa = get_active_artifact(record, record.current_stage)
438
+ try:
439
+ body = read_artifact(run_path.parent, record.current_stage).strip()
440
+ except FileNotFoundError:
441
+ body = ""
442
+ open_owner_route = _open_owner_route(record)
443
+ if aa is None or not body:
444
+ content_file = _prepare_input_file(run_path.parent, stage, "content")
445
+ content_cmd = _stage_content_command(
446
+ base_path,
447
+ work_id,
448
+ stage,
449
+ "stage-produce" if aa is None else "stage-update",
450
+ content_file,
451
+ )
452
+ print(f"stop: needs_content\nstage: {stage}\n"
453
+ f"content_file: {content_file}\n"
454
+ f"next: {content_cmd}\n")
455
+ sys.exit(0)
456
+ if aa.status == "stale":
457
+ content_file = _prepare_input_file(run_path.parent, stage, "repair", body)
458
+ update_cmd = _stage_content_command(
459
+ base_path,
460
+ work_id,
461
+ stage,
462
+ "stage-update",
463
+ content_file,
464
+ )
465
+ repair_status = "upstream_gap_open" if open_owner_route is not None else "stale_artifact"
466
+ print(f"stop: needs_repair\nstatus: {repair_status}\nstage: {stage}\n"
467
+ f"content_file: {content_file}\n"
468
+ f"next: {update_cmd}\n")
469
+ sys.exit(0)
470
+ if aa.status != "ready":
471
+ ready_cmd = _workflow_cli_command(
472
+ base_path,
473
+ ["stage-ready", "--work-id", work_id, "--stage", stage],
474
+ )
475
+ print(f"stop: needs_ready\nstage: {stage}\n"
476
+ "next: review the artifact, then "
477
+ f"{ready_cmd}\n")
478
+ sys.exit(0)
479
+ code = _run_cli(["gate-quality", "--work-id", work_id, "--stage", stage], base_path)
480
+ if code != 0 and manager.load().status == RunStatus.ACTIVE_STAGE_DRAFT:
481
+ # The gate did not change state (e.g. a precondition conflict); surface
482
+ # its output directly instead of looping on the same unchanged status.
483
+ sys.exit(code)
484
+ # On pass -> ready_for_checkpoint_review (opens review-checkpoint below);
485
+ # on structural failure -> quality_gate_failed (surfaced as stop: needs_repair).
486
+ continue
487
+
488
+ if s == RunStatus.READY_FOR_CHECKPOINT_REVIEW:
489
+ route = _open_owner_route(record)
490
+ if route is not None:
491
+ _emit_gap_resolve_stop(base_path, work_id, stage, route.route_id)
492
+ sys.exit(0)
493
+ code = _run_cli(["review-checkpoint", "--work-id", work_id, "--stage", stage], base_path)
494
+ if code != 0:
495
+ sys.exit(code)
496
+ record = manager.load()
497
+ route = _open_owner_route(record)
498
+ if route is not None:
499
+ _emit_gap_resolve_stop(base_path, work_id, stage, route.route_id)
500
+ sys.exit(0)
501
+ _emit_checkpoint_stop(base_path, work_id, stage, record, run_path.parent)
502
+ sys.exit(0)
503
+
504
+ if s == RunStatus.CHECKPOINT_REVIEW:
505
+ route = _open_owner_route(record)
506
+ if route is not None:
507
+ _emit_gap_resolve_stop(base_path, work_id, stage, route.route_id)
508
+ sys.exit(0)
509
+ _emit_checkpoint_stop(base_path, work_id, stage, record, run_path.parent)
510
+ sys.exit(0)
511
+
512
+ if s == RunStatus.CHECKPOINT_APPROVED:
513
+ if record.current_stage == Stage.PLAN:
514
+ code = _run_cli(["run-close", "--work-id", work_id], base_path)
515
+ if code == 0:
516
+ print("done: closing\nplan: 07-plan.md\nnext: hand the PLAN to your executor\n")
517
+ sys.exit(code)
518
+ code = _run_cli(["stage-advance", "--work-id", work_id], base_path)
519
+ if code != 0:
520
+ sys.exit(code)
521
+ continue # reload and run the NEXT_STAGE entry gate in the same continue call
522
+
523
+ if s == RunStatus.NEXT_STAGE:
524
+ code = _run_cli(["gate-entry", "--work-id", work_id, "--stage", stage], base_path)
525
+ if code != 0:
526
+ retry_cmd = _workflow_cli_command(
527
+ base_path,
528
+ ["gate-entry", "--work-id", work_id, "--stage", stage],
529
+ )
530
+ print(f"stop: entry_gate_failed\nstage: {stage}\n"
531
+ "next: repair upstream and rerun "
532
+ f"{retry_cmd}\n")
533
+ sys.exit(code)
534
+ record = manager.load()
535
+ stage = record.current_stage.value
536
+ aa = get_active_artifact(record, record.current_stage)
537
+ if aa is not None and aa.status == "stale":
538
+ try:
539
+ body = read_artifact(run_path.parent, record.current_stage).strip()
540
+ except FileNotFoundError:
541
+ body = ""
542
+ content_file = _prepare_input_file(run_path.parent, stage, "repair", body)
543
+ update_cmd = _stage_content_command(
544
+ base_path,
545
+ work_id,
546
+ stage,
547
+ "stage-update",
548
+ content_file,
549
+ )
550
+ print(f"stop: needs_repair\nstatus: stale_artifact\nstage: {stage}\n"
551
+ f"content_file: {content_file}\n"
552
+ f"next: {update_cmd}\n")
553
+ sys.exit(0)
554
+ content_file = _prepare_input_file(run_path.parent, stage, "content")
555
+ produce_cmd = _stage_content_command(
556
+ base_path,
557
+ work_id,
558
+ stage,
559
+ "stage-produce",
560
+ content_file,
561
+ )
562
+ print(f"stop: entered_stage\nstage: {stage}\n"
563
+ f"content_file: {content_file}\n"
564
+ f"next: {produce_cmd}\n")
565
+ sys.exit(0)
566
+
567
+ if s == RunStatus.ENTRY_GATE_FAILED:
568
+ retry_cmd = _workflow_cli_command(
569
+ base_path,
570
+ ["gate-entry", "--work-id", work_id, "--stage", stage],
571
+ )
572
+ print(f"stop: entry_gate_failed\nstage: {stage}\n"
573
+ "next: repair upstream checkpoints, then "
574
+ f"{retry_cmd}\n")
575
+ sys.exit(0)
576
+
577
+ if s in (RunStatus.QUALITY_GATE_FAILED, RunStatus.CHECKPOINT_CHANGES_REQUESTED):
578
+ try:
579
+ seed = read_artifact(run_path.parent, record.current_stage)
580
+ except FileNotFoundError:
581
+ seed = ""
582
+ content_file = _prepare_input_file(run_path.parent, stage, "repair", seed)
583
+ update_cmd = _stage_content_command(
584
+ base_path,
585
+ work_id,
586
+ stage,
587
+ "stage-update",
588
+ content_file,
589
+ )
590
+ print(f"stop: needs_repair\nstatus: {s.value}\nstage: {stage}\n"
591
+ f"content_file: {content_file}\n"
592
+ f"next: {update_cmd}\n")
593
+ sys.exit(0)
594
+
595
+ # Fallback: read-only resume context.
596
+ code = _run_cli(["run-resume", "--work-id", work_id], base_path)
597
+ sys.exit(code)
598
+
599
+
600
+ def _cmd_status(ns: argparse.Namespace, base_path: Path) -> None:
601
+ if ns.all:
602
+ r2p_dir = base_path / ".req-to-plan"
603
+ if not r2p_dir.exists():
604
+ print("no_runs: true\n")
605
+ sys.exit(0)
606
+ for run_md in sorted(r2p_dir.glob("*/run.md")):
607
+ work_id = run_md.parent.name
608
+ _run_cli(["status-run", "--work-id", work_id], base_path)
609
+ sys.exit(0)
610
+
611
+ pointer = read_active_pointer(base_path)
612
+ if not pointer:
613
+ print("no_selected_run: true\nnext: r2p-status --all\n")
614
+ sys.exit(0)
615
+
616
+ work_id = _validate_work_id(pointer["selected_work_id"])
617
+ exit_code = _run_cli(["status-run", "--work-id", work_id], base_path)
618
+ sys.exit(exit_code)
619
+
620
+
621
+ def _cmd_switch(ns: argparse.Namespace, base_path: Path) -> None:
622
+ work_id = _validate_work_id(ns.work_id)
623
+ run_path = base_path / ".req-to-plan" / work_id / "run.md"
624
+ if not run_path.exists():
625
+ print(f"blocked: source_run_not_found\nwork_id: {work_id}\n")
626
+ sys.exit(7)
627
+
628
+ write_active_pointer(base_path, work_id, reason="manual_switch")
629
+ print(f"selected_run: .req-to-plan/{work_id}/run.md\nnext: r2p-continue\n")
630
+
631
+
632
+ def _cmd_reopen(ns: argparse.Namespace, base_path: Path) -> None:
633
+ from_id = _validate_work_id(ns.from_id)
634
+ exit_code = _run_cli(
635
+ [
636
+ "run-reopen",
637
+ "--from", from_id,
638
+ "--stage", ns.stage,
639
+ "--reason", ns.reason,
640
+ ],
641
+ base_path,
642
+ )
643
+ sys.exit(exit_code)
644
+
645
+
646
+ def _cmd_gap_open(ns: argparse.Namespace, base_path: Path) -> None:
647
+ work_id = _validate_work_id(ns.work_id)
648
+ args = [
649
+ "gap-open",
650
+ "--work-id", work_id,
651
+ "--owner-stage", ns.owner_stage,
652
+ "--required-action", ns.required_action,
653
+ ]
654
+ if ns.confirm:
655
+ args.append("--confirm")
656
+ sys.exit(_run_cli(args, base_path))
657
+
658
+
659
+ def _cmd_gap_resolve(ns: argparse.Namespace, base_path: Path) -> None:
660
+ work_id = _validate_work_id(ns.work_id)
661
+ args = ["gap-resolve", "--work-id", work_id, "--route-id", ns.route_id]
662
+ if ns.confirm:
663
+ args.append("--confirm")
664
+ sys.exit(_run_cli(args, base_path))
665
+
666
+
667
+ def _cmd_tier_lock(ns: argparse.Namespace, base_path: Path) -> None:
668
+ work_id = _validate_work_id(ns.work_id)
669
+ run_path = base_path / ".req-to-plan" / work_id / "run.md"
670
+ if not run_path.exists():
671
+ print(f"blocked: source_run_not_found\nwork_id: {work_id}\n")
672
+ sys.exit(7)
673
+
674
+ from tools.workflow_cli.state import RunStateManager
675
+ record = RunStateManager(run_path.parent).load()
676
+ if record.status != RunStatus.ACTIVE_STAGE_DRAFT:
677
+ print(
678
+ "blocked: tier_lock_not_allowed\n"
679
+ f"work_id: {work_id}\n"
680
+ f"status: {record.status.value}\n"
681
+ "must_be: active_stage_draft\n"
682
+ )
683
+ sys.exit(EXIT_CONFLICT)
684
+
685
+ args = [
686
+ "tier-lock",
687
+ "--work-id", work_id,
688
+ "--base", ns.base,
689
+ ]
690
+ if ns.modifiers:
691
+ args.extend(["--modifiers", ns.modifiers])
692
+ if ns.override_floor:
693
+ args.append("--override-floor")
694
+ if ns.confirm:
695
+ args.append("--confirm")
696
+ sys.exit(_run_cli(args, base_path))
697
+
698
+
699
+ # ---------------------------------------------------------------------------
700
+ # Argument parser
701
+ # ---------------------------------------------------------------------------
702
+
703
+
704
+ def _build_parser() -> argparse.ArgumentParser:
705
+ parser = argparse.ArgumentParser(prog="r2p", description="req-to-plan agent shortcuts")
706
+ sub = parser.add_subparsers(dest="subcommand", required=True)
707
+
708
+ p_start = sub.add_parser("start")
709
+ p_start.add_argument("requirement", nargs="?", default=None)
710
+ p_start.add_argument("--separate", action="store_true")
711
+ p_start.add_argument(
712
+ "--file",
713
+ dest="file",
714
+ default=None,
715
+ help="Read the requirement from a file instead of a positional argument",
716
+ )
717
+
718
+ sub.add_parser("continue")
719
+
720
+ p_status = sub.add_parser("status")
721
+ p_status.add_argument("--all", action="store_true")
722
+
723
+ p_switch = sub.add_parser("switch")
724
+ p_switch.add_argument("--work-id", dest="work_id", required=True)
725
+
726
+ p_reopen = sub.add_parser("reopen")
727
+ p_reopen.add_argument("--from", dest="from_id", required=True)
728
+ p_reopen.add_argument("--stage", required=True)
729
+ p_reopen.add_argument("--reason", required=True)
730
+
731
+ p_tier_lock = sub.add_parser("tier-lock")
732
+ p_tier_lock.add_argument("--work-id", dest="work_id", required=True)
733
+ p_tier_lock.add_argument("--base", required=True, choices=["light", "standard"])
734
+ p_tier_lock.add_argument("--modifiers", default=None)
735
+ p_tier_lock.add_argument("--override-floor", action="store_true")
736
+ p_tier_lock.add_argument("--confirm", action="store_true")
737
+
738
+ p_gap_open = sub.add_parser("gap-open")
739
+ p_gap_open.add_argument("--work-id", dest="work_id", required=True)
740
+ p_gap_open.add_argument("--owner-stage", dest="owner_stage", required=True)
741
+ p_gap_open.add_argument("--required-action", dest="required_action", required=True)
742
+ p_gap_open.add_argument("--confirm", action="store_true")
743
+
744
+ p_gap_resolve = sub.add_parser("gap-resolve")
745
+ p_gap_resolve.add_argument("--work-id", dest="work_id", required=True)
746
+ p_gap_resolve.add_argument("--route-id", dest="route_id", required=True)
747
+ p_gap_resolve.add_argument("--confirm", action="store_true")
748
+
749
+ return parser
750
+
751
+
752
+ # ---------------------------------------------------------------------------
753
+ # Entry point
754
+ # ---------------------------------------------------------------------------
755
+
756
+
757
+ def main(args: list[str] | None = None, base_path: Path | None = None) -> None:
758
+ parser = _build_parser()
759
+ ns = parser.parse_args(args)
760
+
761
+ bp = base_path or Path.cwd()
762
+
763
+ handlers = {
764
+ "start": _cmd_start,
765
+ "continue": _cmd_continue,
766
+ "status": _cmd_status,
767
+ "switch": _cmd_switch,
768
+ "reopen": _cmd_reopen,
769
+ "tier-lock": _cmd_tier_lock,
770
+ "gap-open": _cmd_gap_open,
771
+ "gap-resolve": _cmd_gap_resolve,
772
+ }
773
+ handlers[ns.subcommand](ns, bp)
774
+ sys.exit(0)
775
+
776
+
777
+ if __name__ == "__main__":
778
+ main()