@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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/README.zh-CN.md +158 -0
- package/bin/r2p.js +38 -0
- package/docs/req-to-plan-design.md +277 -0
- package/package.json +47 -0
- package/requirements.txt +1 -0
- package/tools/r2p +10 -0
- package/tools/r2p-continue +10 -0
- package/tools/r2p-gap-open +10 -0
- package/tools/r2p-gap-resolve +10 -0
- package/tools/r2p-reopen +10 -0
- package/tools/r2p-start +10 -0
- package/tools/r2p-status +10 -0
- package/tools/r2p-switch +10 -0
- package/tools/r2p-tier-lock +10 -0
- package/tools/workflow_cli/__init__.py +0 -0
- package/tools/workflow_cli/__main__.py +5 -0
- package/tools/workflow_cli/agent_shortcuts.py +778 -0
- package/tools/workflow_cli/agent_templates/claude/SKILL.md +34 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +16 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-open.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-resolve.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +10 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-status.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-switch.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-tier-lock.md +8 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-open/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-resolve/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +14 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-status/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-switch/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-tier-lock/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-open.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-resolve.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-status.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-switch.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-tier-lock.toml +4 -0
- package/tools/workflow_cli/artifact.py +228 -0
- package/tools/workflow_cli/cli.py +1779 -0
- package/tools/workflow_cli/gates.py +471 -0
- package/tools/workflow_cli/install.py +900 -0
- package/tools/workflow_cli/install_cli.py +158 -0
- package/tools/workflow_cli/link_expander.py +102 -0
- package/tools/workflow_cli/models.py +504 -0
- package/tools/workflow_cli/output.py +91 -0
- package/tools/workflow_cli/repo_baseline.py +137 -0
- package/tools/workflow_cli/state.py +621 -0
- package/tools/workflow_cli/tier.py +201 -0
- package/tools/workflow_cli/tier_keywords.yaml +45 -0
- 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()
|