@xenonbyte/req-2-plan 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -125
- package/README.zh-CN.md +154 -108
- package/package.json +1 -1
- package/tools/r2p-archive +10 -0
- package/tools/r2p-execute +10 -0
- package/tools/workflow_cli/agent_shortcuts.py +310 -26
- package/tools/workflow_cli/agent_templates/claude/SKILL.md +2 -1
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-archive.md +10 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +1 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-execute.md +122 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +2 -2
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-archive/SKILL.md +14 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +2 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-execute/SKILL.md +123 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +2 -2
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-archive.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +1 -1
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-execute.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +1 -1
- package/tools/workflow_cli/artifact.py +5 -2
- package/tools/workflow_cli/atomic.py +50 -0
- package/tools/workflow_cli/cli.py +229 -31
- package/tools/workflow_cli/gates.py +149 -2
- package/tools/workflow_cli/install.py +56 -3
- package/tools/workflow_cli/link_expander.py +9 -25
- package/tools/workflow_cli/markdown.py +18 -0
- package/tools/workflow_cli/models.py +19 -1
- package/tools/workflow_cli/stage_templates.py +2 -1
- package/tools/workflow_cli/state.py +5 -6
- package/tools/workflow_cli/tier.py +1 -1
- package/tools/workflow_cli/version.py +1 -1
- package/tools/workflow_cli/workspace.py +112 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: r2p-execute
|
|
3
|
+
description: Execute a closed run's PLAN in place on the current branch via the subagent-driven SDD loop, then archive. Use when the user asks to run r2p-execute, execute the plan, start execution of the r2p run, or implement the PLAN tasks.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# r2p-execute — SDD Execution Loop
|
|
7
|
+
|
|
8
|
+
Execute each PLAN-TASK on the **current branch** using a fresh implementer subagent per task, a task-reviewer after each, and a whole-branch review at the end.
|
|
9
|
+
|
|
10
|
+
**Subagents are a hard prerequisite.** If this platform cannot dispatch subagents, fail explicitly and let the human decide — never silently fall back to sequential execution.
|
|
11
|
+
|
|
12
|
+
## Precondition Gate
|
|
13
|
+
|
|
14
|
+
Run `{{R2P_BIN_DIR}}/r2p-execute` and read its stop output. On a `closed_at_plan_checkpoint` run it transitions the run to `executing` (via the `run-execute-start` CLI command) and stops with `execute_plan`; on an already-`executing` run it stops with `resume_execution`; on any other status it stops with `plan_not_ready`.
|
|
15
|
+
|
|
16
|
+
- If the run status is `closed_at_plan_checkpoint` (first execution): the command transitions it to `executing` and returns the plan path.
|
|
17
|
+
- If the run status is already `executing` (resume after interruption): proceed directly to the plan path.
|
|
18
|
+
- Any other status → `plan_not_ready`. Stop and tell the user.
|
|
19
|
+
|
|
20
|
+
## In-Place Execution (no branch)
|
|
21
|
+
|
|
22
|
+
Work directly on the **current branch** — do NOT create a new branch, worktree, or protection boundary.
|
|
23
|
+
|
|
24
|
+
Before task 1, run `git status --short -- ':!.req-to-plan'` to check the code working tree (excluding `.req-to-plan/` state). If there are uncommitted changes, stop before dispatching Task 1 and ask the user to clean, stash, or commit that work, or to explicitly identify which dirty paths belong to this execution and approve task-only staging. Do not commit unrelated work. `push` and PR creation are out of scope; request them explicitly from the user.
|
|
25
|
+
|
|
26
|
+
## Pre-flight Plan Review
|
|
27
|
+
|
|
28
|
+
Before dispatching Task 1, read `07-plan.md` once and scan for:
|
|
29
|
+
- Tasks that contradict each other or the plan's Global Constraints
|
|
30
|
+
- Items the plan mandates that the review rubric would treat as a defect
|
|
31
|
+
|
|
32
|
+
Batch all findings into one question to the human **before** execution begins — one interrupt, not one per discovery. If the scan is clean, proceed without comment. The task-reviewer loop catches conflicts that only emerge from implementation.
|
|
33
|
+
If a finding requires PLAN, SPEC, or DESIGN repair, stop and ask the human to reopen from the affected stage rather than patching over it in execution.
|
|
34
|
+
|
|
35
|
+
## Per-Task Loop
|
|
36
|
+
|
|
37
|
+
For each PLAN-TASK (in order):
|
|
38
|
+
|
|
39
|
+
### 1. Extract task inline
|
|
40
|
+
|
|
41
|
+
Read the task text directly from `07-plan.md`. Note the task's `Skeleton`, `Steps`, `Spec References`, and `Verification` criteria.
|
|
42
|
+
|
|
43
|
+
### 2. Dispatch a fresh implementer subagent
|
|
44
|
+
|
|
45
|
+
Provide the subagent with:
|
|
46
|
+
- The task text (from `07-plan.md`)
|
|
47
|
+
- Scene-setting context (project, dependencies, architectural constraints)
|
|
48
|
+
- TDD instructions: follow `Skeleton`/`Steps`; prove `Verification` with evidence
|
|
49
|
+
- A report file path (`execution/task-N-report.md`)
|
|
50
|
+
|
|
51
|
+
The implementer must:
|
|
52
|
+
1. Implement exactly what the task specifies, following TDD
|
|
53
|
+
2. Satisfy the task's `Verification` criteria and attach evidence (test output, assertions)
|
|
54
|
+
3. Commit the work, staging only files intentionally changed for this PLAN-TASK
|
|
55
|
+
4. Self-review and report back
|
|
56
|
+
|
|
57
|
+
### 3. Handle implementer status
|
|
58
|
+
|
|
59
|
+
- **DONE / DONE_WITH_CONCERNS**: proceed to review
|
|
60
|
+
- **NEEDS_CONTEXT**: the implementer needs missing information — provide it and re-dispatch the fresh implementer subagent
|
|
61
|
+
- **BLOCKED**: assess the blocker; provide context, use a more capable model, or break the task into smaller pieces; escalate to the human if the plan itself is wrong
|
|
62
|
+
|
|
63
|
+
### 4. Ambiguity ladder
|
|
64
|
+
|
|
65
|
+
The fresh implementer subagent verifies-then-removes ambiguity by evidence and TDD. If it cannot resolve ambiguity:
|
|
66
|
+
- Return `NEEDS_CONTEXT` or `BLOCKED` and escalate to the human — never guess a vague implementation
|
|
67
|
+
- If the ambiguity is an upstream PLAN, SPEC, or DESIGN defect, stop and ask the human to choose an upstream repair path (for example, reopening from the affected stage) rather than patching over it in execution. Do not try to open a gap route from an `executing` run.
|
|
68
|
+
|
|
69
|
+
### 5. Write diff and dispatch task-reviewer
|
|
70
|
+
|
|
71
|
+
After the implementer reports DONE:
|
|
72
|
+
1. Record the diff inline: `git diff -U10 <base-commit> HEAD`
|
|
73
|
+
2. Dispatch a task-reviewer subagent with:
|
|
74
|
+
- The task text and `Spec References` from `07-plan.md`
|
|
75
|
+
- The implementer's report
|
|
76
|
+
- The diff
|
|
77
|
+
- Global constraints from the plan
|
|
78
|
+
|
|
79
|
+
The task-reviewer returns two verdicts:
|
|
80
|
+
- **Spec compliance**: checked against `Spec References` + `Verification`
|
|
81
|
+
- **Code quality**: clean, tested, maintainable
|
|
82
|
+
|
|
83
|
+
### 6. Fix loop
|
|
84
|
+
|
|
85
|
+
- Dispatch fix subagents for Critical and Important findings
|
|
86
|
+
- Re-dispatch the task-reviewer after each fix wave
|
|
87
|
+
- Only when the task-reviewer is clean (both spec ✅ and quality Approved, and `Verification` satisfied), update the matching `execution/progress.md` checkbox from `- [ ] PLAN-TASK-NNN ...` to `- [x] PLAN-TASK-NNN ...` and append one line:
|
|
88
|
+
`Task N: complete (commits <base7>..<head7>, review clean)`
|
|
89
|
+
|
|
90
|
+
## Final Whole-Branch Review
|
|
91
|
+
|
|
92
|
+
After all tasks complete, dispatch a final whole-branch review subagent:
|
|
93
|
+
- Scope: all commits since the branch started (or since `closed_at_plan_checkpoint`)
|
|
94
|
+
- Include the diff (`git diff -U10 <merge-base> HEAD`)
|
|
95
|
+
- This whole-branch review is the merge gate
|
|
96
|
+
- Dispatch fix subagents for any Critical/Important findings before marking done
|
|
97
|
+
|
|
98
|
+
## Auto-Archive on Completion
|
|
99
|
+
|
|
100
|
+
When all tasks are done and the final whole-branch review is clean, call:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
{{R2P_BIN_DIR}}/r2p-archive --work-id <work_id from the precondition output>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Archiving is gated: `r2p-archive` refuses unless every PLAN-TASK from the PLAN is checked off (`- [x]`) in the ledger. Add `--force` only to archive an abandoned or superseded run.
|
|
107
|
+
|
|
108
|
+
Commits are already on the **current branch**. `push` and PR creation still require an explicit user request.
|
|
109
|
+
|
|
110
|
+
## Durable Progress
|
|
111
|
+
|
|
112
|
+
Track progress in `execution/progress.md` (not only in todos). On resume, read the ledger and skip tasks already marked complete.
|
|
113
|
+
|
|
114
|
+
## Error Reference
|
|
115
|
+
|
|
116
|
+
| Condition | Action |
|
|
117
|
+
|---|---|
|
|
118
|
+
| Status not `closed_at_plan_checkpoint` or `executing` | Stop: `plan_not_ready` |
|
|
119
|
+
| Implementer returns `NEEDS_CONTEXT` | Provide missing context, re-dispatch fresh implementer subagent |
|
|
120
|
+
| Upstream PLAN/SPEC/DESIGN defect found | Stop: ask the human to reopen/repair the upstream stage |
|
|
121
|
+
| Platform lacks subagent capability | Fail explicitly (subagents are a hard prerequisite) |
|
|
122
|
+
|
|
123
|
+
Use `r2p-status` to inspect progress without making changes.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: r2p-reopen
|
|
3
|
-
description: Reopen a closed requirement-to-PLAN workflow run from a specific stage. Use when the user asks to run r2p-reopen or reopen an r2p run because an upstream stage needs repair.
|
|
3
|
+
description: Reopen a closed or executing requirement-to-PLAN workflow run from a specific stage. Use when the user asks to run r2p-reopen or reopen an r2p run because an upstream stage needs repair.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# r2p-reopen
|
|
7
7
|
|
|
8
|
-
Run `{{R2P_BIN_DIR}}/r2p-reopen` to reopen a run that was closed at the PLAN checkpoint.
|
|
8
|
+
Run `{{R2P_BIN_DIR}}/r2p-reopen` to reopen a run that was closed at the PLAN checkpoint or is already executing. On success, it selects the reopened run as the active run.
|
|
9
9
|
|
|
10
10
|
Usage: `{{R2P_BIN_DIR}}/r2p-reopen --from <work-id> --stage <stage> --reason "<text>"`
|
|
11
11
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
name = "r2p-continue"
|
|
2
|
-
description = "Continue the active requirement-to-PLAN workflow run"
|
|
2
|
+
description = "Continue the active requirement-to-PLAN workflow run. At checkpoints, only approve DESIGN/SPEC/PLAN artifacts with no unresolved ambiguity or undecided point."
|
|
3
3
|
command = "{{R2P_BIN_DIR}}/r2p-continue"
|
|
4
4
|
version = "{{R2P_VERSION}}"
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
name = "r2p-execute"
|
|
2
|
+
description = "Execute a closed run's PLAN in place on the current branch via the subagent-driven SDD loop, then archive. Implements each PLAN-TASK with a fresh implementer subagent + task review; subagents are required."
|
|
3
|
+
command = "{{R2P_BIN_DIR}}/r2p-execute"
|
|
4
|
+
version = "{{R2P_VERSION}}"
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
from datetime import datetime, timezone
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from tools.workflow_cli.atomic import atomic_write_text
|
|
12
13
|
from tools.workflow_cli.models import Stage, STAGE_ARTIFACT_MAP
|
|
13
14
|
|
|
14
15
|
|
|
@@ -98,7 +99,9 @@ def write_artifact(
|
|
|
98
99
|
fm, _ = _parse_frontmatter(path.read_text(encoding="utf-8"))
|
|
99
100
|
created_at = fm.get("r2p_created_at", now)
|
|
100
101
|
full_text = _frontmatter(stage, version, status, created_at, now) + content
|
|
101
|
-
|
|
102
|
+
# Atomic write: a crash mid-write must not leave a truncated artifact that
|
|
103
|
+
# fails to parse on the next load. Write a unique sibling temp then replace.
|
|
104
|
+
atomic_write_text(path, full_text)
|
|
102
105
|
return path
|
|
103
106
|
|
|
104
107
|
|
|
@@ -225,4 +228,4 @@ class ArtifactManager:
|
|
|
225
228
|
f"r2p_replaced_by: {replaced_by}\n"
|
|
226
229
|
f"---\n\n"
|
|
227
230
|
) + body
|
|
228
|
-
path
|
|
231
|
+
atomic_write_text(path, content)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Atomic filesystem write helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def atomic_write_text(path: Path, content: str, *, encoding: str = "utf-8") -> None:
|
|
10
|
+
"""Write text via a unique sibling temp file, then atomically replace path.
|
|
11
|
+
|
|
12
|
+
Guarantees atomic-replace semantics (a reader sees either the old or the new
|
|
13
|
+
file, never a truncated one). It does NOT fsync — durability across power
|
|
14
|
+
loss / crash is a deliberate non-goal for this CLI.
|
|
15
|
+
"""
|
|
16
|
+
tmp_path, fd = _open_unique_sibling_tmp(path)
|
|
17
|
+
try:
|
|
18
|
+
with os.fdopen(fd, "w", encoding=encoding) as tmp:
|
|
19
|
+
fd = -1
|
|
20
|
+
tmp.write(content)
|
|
21
|
+
os.replace(tmp_path, path)
|
|
22
|
+
tmp_path = None
|
|
23
|
+
finally:
|
|
24
|
+
if fd != -1:
|
|
25
|
+
os.close(fd)
|
|
26
|
+
if tmp_path is not None:
|
|
27
|
+
try:
|
|
28
|
+
tmp_path.unlink()
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _open_unique_sibling_tmp(path: Path) -> tuple[Path, int]:
|
|
34
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
35
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
36
|
+
flags |= os.O_CLOEXEC
|
|
37
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
38
|
+
flags |= os.O_NOFOLLOW
|
|
39
|
+
|
|
40
|
+
last_error: FileExistsError | None = None
|
|
41
|
+
for _ in range(100):
|
|
42
|
+
candidate = path.with_name(
|
|
43
|
+
f".{path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
return candidate, os.open(candidate, flags, 0o666)
|
|
47
|
+
except FileExistsError as exc:
|
|
48
|
+
last_error = exc
|
|
49
|
+
|
|
50
|
+
raise FileExistsError(f"Could not create unique temp file for {path}") from last_error
|
|
@@ -42,7 +42,12 @@ from tools.workflow_cli.artifact import (
|
|
|
42
42
|
get_artifact_version,
|
|
43
43
|
update_artifact_status,
|
|
44
44
|
)
|
|
45
|
-
from tools.workflow_cli.gates import
|
|
45
|
+
from tools.workflow_cli.gates import (
|
|
46
|
+
check_entry_gate,
|
|
47
|
+
check_quality_gate,
|
|
48
|
+
check_forced_subagent_review,
|
|
49
|
+
check_execution_complete,
|
|
50
|
+
)
|
|
46
51
|
from tools.workflow_cli.output import (
|
|
47
52
|
format_success,
|
|
48
53
|
format_error,
|
|
@@ -56,6 +61,9 @@ from tools.workflow_cli.output import (
|
|
|
56
61
|
EXIT_NOT_FOUND,
|
|
57
62
|
)
|
|
58
63
|
from tools.workflow_cli.tier import estimate_tier, scan_keywords
|
|
64
|
+
from tools.workflow_cli.workspace import ensure_workspace_gitignore, commit_requirement_dir
|
|
65
|
+
from tools.workflow_cli.atomic import atomic_write_text
|
|
66
|
+
from tools.workflow_cli.markdown import plan_task_anchors, strip_readonly_sections
|
|
59
67
|
|
|
60
68
|
|
|
61
69
|
# ---------------------------------------------------------------------------
|
|
@@ -70,8 +78,16 @@ def _get_run_dir(work_id: str, base_path: Path | None = None) -> Path:
|
|
|
70
78
|
|
|
71
79
|
|
|
72
80
|
def _load_run(work_id: str, base_path: Path | None = None):
|
|
73
|
-
"""Load RunRecord; exit with EXIT_NOT_FOUND if not found.
|
|
81
|
+
"""Load RunRecord; exit with EXIT_NOT_FOUND if not found.
|
|
82
|
+
|
|
83
|
+
Rejects a symlinked workspace or run directory up front (EXIT_CONFLICT) so
|
|
84
|
+
no command — read-only or mutating — ever follows `.req-to-plan` or
|
|
85
|
+
`.req-to-plan/<id>` out of the workspace.
|
|
86
|
+
"""
|
|
87
|
+
root = base_path or Path.cwd()
|
|
88
|
+
_reject_symlink_or_exit(root / ".req-to-plan", "unsafe_workspace_dir_symlink")
|
|
74
89
|
run_dir = _get_run_dir(work_id, base_path)
|
|
90
|
+
_reject_symlink_or_exit(run_dir, f"Run directory is a symlink: {run_dir}")
|
|
75
91
|
mgr = RunStateManager(run_dir)
|
|
76
92
|
try:
|
|
77
93
|
return mgr.load(), mgr, run_dir
|
|
@@ -90,6 +106,18 @@ def _validate_work_id(raw: str) -> WorkId:
|
|
|
90
106
|
print_and_exit(format_error(str(e), exit_code=EXIT_CLI_ERR), EXIT_CLI_ERR)
|
|
91
107
|
|
|
92
108
|
|
|
109
|
+
def _ensure_workspace_gitignore_or_exit(base_path: Path) -> None:
|
|
110
|
+
try:
|
|
111
|
+
ensure_workspace_gitignore(base_path)
|
|
112
|
+
except ValueError as e:
|
|
113
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _reject_symlink_or_exit(path: Path, message: str) -> None:
|
|
117
|
+
if path.is_symlink():
|
|
118
|
+
print_and_exit(format_error(message, exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
|
|
119
|
+
|
|
120
|
+
|
|
93
121
|
def _validate_repo_path(raw: str) -> Path:
|
|
94
122
|
"""Return a repo path only when it is an existing directory."""
|
|
95
123
|
repo_path = Path(raw)
|
|
@@ -231,8 +259,8 @@ def _cmd_run_start(args):
|
|
|
231
259
|
link_results = []
|
|
232
260
|
if repo_path is not None:
|
|
233
261
|
from tools.workflow_cli.link_expander import expand_links
|
|
234
|
-
# Local relative links expand; HTTP
|
|
235
|
-
link_results = expand_links(requirement, base_path=repo_path
|
|
262
|
+
# Local relative links expand; HTTP URLs are recorded as external references only.
|
|
263
|
+
link_results = expand_links(requirement, base_path=repo_path)
|
|
236
264
|
|
|
237
265
|
# Tier estimation
|
|
238
266
|
tier_estimate, evidence = estimate_tier(requirement, repo_path=repo_path, link_results=link_results)
|
|
@@ -371,6 +399,8 @@ def _cmd_run_close(args):
|
|
|
371
399
|
),
|
|
372
400
|
EXIT_CONFLICT,
|
|
373
401
|
)
|
|
402
|
+
base = args.base_path or Path.cwd()
|
|
403
|
+
_ensure_workspace_gitignore_or_exit(base)
|
|
374
404
|
try:
|
|
375
405
|
record = update_run_status(record, RunStatus.CLOSED_AT_PLAN_CHECKPOINT)
|
|
376
406
|
except ValueError as e:
|
|
@@ -378,6 +408,13 @@ def _cmd_run_close(args):
|
|
|
378
408
|
record.current_stage = Stage.CLOSED
|
|
379
409
|
update_resume_context(record, last_operation="close_at_plan_checkpoint")
|
|
380
410
|
mgr.save(record)
|
|
411
|
+
# PLAN complete → land the requirement directory in version control
|
|
412
|
+
# (best-effort, path-limited; never touches unrelated changes). spec §4.5
|
|
413
|
+
commit_requirement_dir(
|
|
414
|
+
base,
|
|
415
|
+
str(record.work_id),
|
|
416
|
+
f"chore(r2p): plan {record.work_id}",
|
|
417
|
+
)
|
|
381
418
|
print_and_exit(
|
|
382
419
|
format_success({"work_id": str(record.work_id), "status": record.status.value}, message="Run closed"),
|
|
383
420
|
EXIT_OK,
|
|
@@ -388,22 +425,17 @@ def _cmd_run_reopen(args):
|
|
|
388
425
|
source_id = str(_validate_work_id(args.from_id))
|
|
389
426
|
target_stage = _parse_reopen_stage(args.stage)
|
|
390
427
|
|
|
391
|
-
# Load source run
|
|
392
|
-
|
|
393
|
-
source_mgr =
|
|
394
|
-
try:
|
|
395
|
-
source_record = source_mgr.load()
|
|
396
|
-
except FileNotFoundError:
|
|
397
|
-
print_and_exit(
|
|
398
|
-
format_error(f"Source run not found: {source_id}", exit_code=EXIT_NOT_FOUND),
|
|
399
|
-
EXIT_NOT_FOUND,
|
|
400
|
-
)
|
|
428
|
+
# Load source run through the shared guard so reopen cannot follow a
|
|
429
|
+
# symlinked .req-to-plan/<work-id> outside the workspace.
|
|
430
|
+
source_record, source_mgr, source_dir = _load_run(source_id, args.base_path)
|
|
401
431
|
|
|
402
|
-
|
|
432
|
+
reopenable_statuses = {RunStatus.CLOSED_AT_PLAN_CHECKPOINT, RunStatus.EXECUTING}
|
|
433
|
+
if source_record.status not in reopenable_statuses:
|
|
403
434
|
print_and_exit(
|
|
404
435
|
format_error(
|
|
405
|
-
f"Source run {source_id!r} is not
|
|
406
|
-
f"(status={source_record.status.value!r})"
|
|
436
|
+
f"Source run {source_id!r} is not reopenable "
|
|
437
|
+
f"(status={source_record.status.value!r}); must be "
|
|
438
|
+
"closed_at_plan_checkpoint or executing",
|
|
407
439
|
exit_code=EXIT_CONFLICT,
|
|
408
440
|
),
|
|
409
441
|
EXIT_CONFLICT,
|
|
@@ -428,7 +460,8 @@ def _cmd_run_reopen(args):
|
|
|
428
460
|
except ValueError as e:
|
|
429
461
|
print_and_exit(format_error(str(e), exit_code=EXIT_CLI_ERR), EXIT_CLI_ERR)
|
|
430
462
|
candidate_dir = _get_run_dir(candidate, args.base_path)
|
|
431
|
-
|
|
463
|
+
candidate_archive_dir = (args.base_path or Path.cwd()) / ".req-to-plan" / "archive" / candidate
|
|
464
|
+
if not candidate_dir.exists() and not candidate_archive_dir.exists():
|
|
432
465
|
new_work_id = candidate_work_id
|
|
433
466
|
new_work_id_str = candidate
|
|
434
467
|
new_run_dir = candidate_dir
|
|
@@ -463,7 +496,12 @@ def _cmd_run_reopen(args):
|
|
|
463
496
|
new_record = create_run_record(new_work_id)
|
|
464
497
|
new_record.tier_estimate = source_record.tier_estimate
|
|
465
498
|
new_record.tier_locked = source_record.tier_locked
|
|
466
|
-
|
|
499
|
+
source_phase = (
|
|
500
|
+
"execution"
|
|
501
|
+
if source_record.status == RunStatus.EXECUTING
|
|
502
|
+
else "plan_checkpoint"
|
|
503
|
+
)
|
|
504
|
+
new_record.reopen_lineage = f"reopened_from: {source_id}@{source_phase} reason: {args.reason}"
|
|
467
505
|
new_record.current_stage = target_stage
|
|
468
506
|
|
|
469
507
|
# Copy approved checkpoints before target stage
|
|
@@ -473,9 +511,38 @@ def _cmd_run_reopen(args):
|
|
|
473
511
|
if cp_stage_idx < target_idx:
|
|
474
512
|
new_record.approved_checkpoints.append(cp)
|
|
475
513
|
|
|
514
|
+
# Repopulate active_artifacts for copied stages so the reopened record
|
|
515
|
+
# matches the on-disk artifacts and approved checkpoints.
|
|
516
|
+
for cp in new_record.approved_checkpoints:
|
|
517
|
+
# Invariant: cp.artifact == STAGE_ARTIFACT_MAP[cp.stage] (artifacts are
|
|
518
|
+
# only ever created via the map, artifact.py:23). The existence check and
|
|
519
|
+
# the recorded name are therefore interchangeable; no drift path exists.
|
|
520
|
+
artifact_name = STAGE_ARTIFACT_MAP.get(cp.stage)
|
|
521
|
+
if artifact_name and (new_run_dir / artifact_name).exists():
|
|
522
|
+
upsert_active_artifact(
|
|
523
|
+
new_record,
|
|
524
|
+
stage=cp.stage,
|
|
525
|
+
artifact=cp.artifact,
|
|
526
|
+
version=cp.version,
|
|
527
|
+
status="approved",
|
|
528
|
+
)
|
|
529
|
+
|
|
476
530
|
new_mgr = RunStateManager(new_run_dir)
|
|
477
531
|
new_mgr.save(new_record)
|
|
478
532
|
|
|
533
|
+
if source_record.status == RunStatus.EXECUTING:
|
|
534
|
+
try:
|
|
535
|
+
source_record = update_run_status(source_record, RunStatus.CLOSED_AT_PLAN_CHECKPOINT)
|
|
536
|
+
except ValueError as e:
|
|
537
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
|
|
538
|
+
source_record.current_stage = Stage.CLOSED
|
|
539
|
+
update_resume_context(
|
|
540
|
+
source_record,
|
|
541
|
+
last_operation="reopen_from_execution",
|
|
542
|
+
next_operation=f"continue_reopened_run:{new_work_id}",
|
|
543
|
+
)
|
|
544
|
+
source_mgr.save(source_record)
|
|
545
|
+
|
|
479
546
|
print_and_exit(
|
|
480
547
|
format_success(
|
|
481
548
|
{
|
|
@@ -490,6 +557,126 @@ def _cmd_run_reopen(args):
|
|
|
490
557
|
)
|
|
491
558
|
|
|
492
559
|
|
|
560
|
+
def _cmd_run_archive(args):
|
|
561
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
562
|
+
base = args.base_path or Path.cwd()
|
|
563
|
+
archivable = {RunStatus.CLOSED_AT_PLAN_CHECKPOINT, RunStatus.EXECUTING}
|
|
564
|
+
if record.status not in archivable:
|
|
565
|
+
print_and_exit(
|
|
566
|
+
format_error(
|
|
567
|
+
f"Cannot archive run in status {record.status.value!r}; "
|
|
568
|
+
"must be closed_at_plan_checkpoint or executing",
|
|
569
|
+
exit_code=EXIT_CONFLICT,
|
|
570
|
+
),
|
|
571
|
+
EXIT_CONFLICT,
|
|
572
|
+
)
|
|
573
|
+
# 0. Completion gate: an executing run must show every PLAN-TASK done in its
|
|
574
|
+
# ledger before it can be archived. --force overrides (abandoned/superseded run).
|
|
575
|
+
if record.status == RunStatus.EXECUTING and not args.force:
|
|
576
|
+
gate = check_execution_complete(run_dir)
|
|
577
|
+
if not gate.passed:
|
|
578
|
+
detail = " ".join(gate.issues)
|
|
579
|
+
print_and_exit(
|
|
580
|
+
format_error(
|
|
581
|
+
f"Execution incomplete: {detail} "
|
|
582
|
+
"Re-run with --force to archive an unfinished run.",
|
|
583
|
+
exit_code=gate.exit_code,
|
|
584
|
+
),
|
|
585
|
+
gate.exit_code,
|
|
586
|
+
)
|
|
587
|
+
# 1. Refuse to clobber an existing archived copy before mutating state.
|
|
588
|
+
archive_dir = base / ".req-to-plan" / "archive" / str(record.work_id)
|
|
589
|
+
if archive_dir.exists():
|
|
590
|
+
print_and_exit(
|
|
591
|
+
format_error(
|
|
592
|
+
f"Archive target already exists: {archive_dir}",
|
|
593
|
+
exit_code=EXIT_CONFLICT,
|
|
594
|
+
),
|
|
595
|
+
EXIT_CONFLICT,
|
|
596
|
+
)
|
|
597
|
+
# 2. Ensure /archive is ignored before anything lands under it.
|
|
598
|
+
_ensure_workspace_gitignore_or_exit(base)
|
|
599
|
+
# 3. Build the archived record, but do not persist it until the move succeeds.
|
|
600
|
+
try:
|
|
601
|
+
archived_record = update_run_status(record, RunStatus.ARCHIVED)
|
|
602
|
+
except ValueError as e:
|
|
603
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
|
|
604
|
+
_reject_symlink_or_exit(archive_dir.parent, f"Archive parent is a symlink: {archive_dir.parent}")
|
|
605
|
+
archive_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
606
|
+
# 4. Move the run dir into the (ignored) archive, then persist ARCHIVED there.
|
|
607
|
+
shutil.move(str(run_dir), str(archive_dir))
|
|
608
|
+
try:
|
|
609
|
+
RunStateManager(archive_dir).save(archived_record)
|
|
610
|
+
except Exception:
|
|
611
|
+
if archive_dir.exists() and not run_dir.exists():
|
|
612
|
+
shutil.move(str(archive_dir), str(run_dir))
|
|
613
|
+
raise
|
|
614
|
+
# 5. Commit the removal of the original path (untracks the dir). spec §4.6
|
|
615
|
+
commit_requirement_dir(
|
|
616
|
+
base, str(archived_record.work_id), f"chore(r2p): archive {archived_record.work_id}"
|
|
617
|
+
)
|
|
618
|
+
print_and_exit(
|
|
619
|
+
format_success(
|
|
620
|
+
{"work_id": str(archived_record.work_id), "status": "archived", "archived_to": str(archive_dir)},
|
|
621
|
+
message=f"Run archived: {archived_record.work_id}",
|
|
622
|
+
),
|
|
623
|
+
EXIT_OK,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _cmd_run_execute_start(args):
|
|
628
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
629
|
+
if record.status != RunStatus.CLOSED_AT_PLAN_CHECKPOINT:
|
|
630
|
+
print_and_exit(
|
|
631
|
+
format_error(
|
|
632
|
+
f"Cannot start execution in status {record.status.value!r}; "
|
|
633
|
+
"must be closed_at_plan_checkpoint (plan_not_ready)",
|
|
634
|
+
exit_code=EXIT_CONFLICT,
|
|
635
|
+
),
|
|
636
|
+
EXIT_CONFLICT,
|
|
637
|
+
)
|
|
638
|
+
try:
|
|
639
|
+
plan_text = read_artifact(run_dir, Stage.PLAN)
|
|
640
|
+
except FileNotFoundError:
|
|
641
|
+
print_and_exit(
|
|
642
|
+
format_error("PLAN artifact not found; cannot start execution", exit_code=EXIT_NOT_FOUND),
|
|
643
|
+
EXIT_NOT_FOUND,
|
|
644
|
+
)
|
|
645
|
+
# Seed the structural progress ledger (IDs + checkboxes = structure, not
|
|
646
|
+
# semantics; the agent appends progress). CLI never generates artifact text.
|
|
647
|
+
anchors = plan_task_anchors(strip_readonly_sections(plan_text))
|
|
648
|
+
if not anchors:
|
|
649
|
+
print_and_exit(
|
|
650
|
+
format_error(
|
|
651
|
+
"PLAN contains no PLAN-TASK anchors; repair PLAN with "
|
|
652
|
+
"### PLAN-TASK-NNN headings before execution",
|
|
653
|
+
exit_code=EXIT_CONFLICT,
|
|
654
|
+
),
|
|
655
|
+
EXIT_CONFLICT,
|
|
656
|
+
)
|
|
657
|
+
lines = ["# Execution Progress", "", f"work_id: {record.work_id}", ""]
|
|
658
|
+
lines += [f"- [ ] {tid} {title}".rstrip() for tid, title in anchors]
|
|
659
|
+
exec_dir = run_dir / "execution"
|
|
660
|
+
_reject_symlink_or_exit(exec_dir, "unsafe_execution_dir_symlink")
|
|
661
|
+
exec_dir.mkdir(parents=True, exist_ok=True)
|
|
662
|
+
atomic_write_text(exec_dir / "progress.md", "\n".join(lines) + "\n")
|
|
663
|
+
record = update_run_status(record, RunStatus.EXECUTING)
|
|
664
|
+
update_resume_context(record, last_operation="execute_start", next_operation="implement_tasks")
|
|
665
|
+
mgr.save(record)
|
|
666
|
+
print_and_exit(
|
|
667
|
+
format_success(
|
|
668
|
+
{
|
|
669
|
+
"work_id": str(record.work_id),
|
|
670
|
+
"status": record.status.value,
|
|
671
|
+
"ledger": str(exec_dir / "progress.md"),
|
|
672
|
+
"task_count": len(anchors),
|
|
673
|
+
},
|
|
674
|
+
message=f"Execution started: {record.work_id}",
|
|
675
|
+
),
|
|
676
|
+
EXIT_OK,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
|
|
493
680
|
def _cmd_gap_resolve(args):
|
|
494
681
|
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
495
682
|
route = next(
|
|
@@ -723,14 +910,6 @@ def _cmd_tier_lock(args):
|
|
|
723
910
|
record.tier_estimate = TierEstimate(base=TierBase.LIGHT, modifiers=frozenset())
|
|
724
911
|
|
|
725
912
|
if args.override_floor:
|
|
726
|
-
if not args.confirm:
|
|
727
|
-
print_and_exit(
|
|
728
|
-
format_error(
|
|
729
|
-
"Overriding floor requires --confirm flag as well",
|
|
730
|
-
exit_code=EXIT_CLI_ERR,
|
|
731
|
-
),
|
|
732
|
-
EXIT_CLI_ERR,
|
|
733
|
-
)
|
|
734
913
|
from tools.workflow_cli.models import TierEstimate
|
|
735
914
|
record.tier_locked = TierEstimate(base=base, modifiers=modifiers)
|
|
736
915
|
else:
|
|
@@ -808,11 +987,12 @@ def _cmd_tier_escalate(args):
|
|
|
808
987
|
active_item=record.current_stage.value,
|
|
809
988
|
)
|
|
810
989
|
|
|
811
|
-
# Revoke affected bundle authorizations that cover high-tier stages
|
|
990
|
+
# Revoke affected bundle authorizations that cover high-tier stages.
|
|
991
|
+
# Reuse the gates definition so bundle revocation stays in lockstep with
|
|
992
|
+
# forced-review behavior if the high-risk modifier set ever changes.
|
|
812
993
|
from tools.workflow_cli.gates import _FORCED_REVIEW_MODIFIERS
|
|
813
|
-
high_modifiers = {TierModifier.MIGRATION, TierModifier.SAFETY, TierModifier.CROSS_PROJECT}
|
|
814
994
|
from datetime import datetime, timezone
|
|
815
|
-
if modifier in
|
|
995
|
+
if modifier in _FORCED_REVIEW_MODIFIERS:
|
|
816
996
|
revoke_ts = datetime.now(timezone.utc).isoformat()
|
|
817
997
|
from tools.workflow_cli.models import STAGE_REQUIRED_UPSTREAM_CHECKPOINTS
|
|
818
998
|
affected_stages = {Stage.DESIGN, Stage.SPEC, Stage.PLAN}
|
|
@@ -1361,12 +1541,30 @@ def _register_run_commands(subparsers):
|
|
|
1361
1541
|
p.set_defaults(func=_cmd_run_close)
|
|
1362
1542
|
|
|
1363
1543
|
# run-reopen
|
|
1364
|
-
p = subparsers.add_parser(
|
|
1544
|
+
p = subparsers.add_parser(
|
|
1545
|
+
"run-reopen",
|
|
1546
|
+
help="Reopen a closed or executing workflow run",
|
|
1547
|
+
)
|
|
1365
1548
|
p.add_argument("--from", dest="from_id", required=True, help="Source work-id to reopen from")
|
|
1366
1549
|
p.add_argument("--stage", required=True, help="Target stage to reopen at")
|
|
1367
1550
|
p.add_argument("--reason", required=True, help="Reason for reopening")
|
|
1368
1551
|
p.set_defaults(func=_cmd_run_reopen)
|
|
1369
1552
|
|
|
1553
|
+
# run-archive
|
|
1554
|
+
p = subparsers.add_parser("run-archive", help="Archive a closed run out of the active workspace")
|
|
1555
|
+
p.add_argument("--work-id", required=True)
|
|
1556
|
+
p.add_argument(
|
|
1557
|
+
"--force",
|
|
1558
|
+
action="store_true",
|
|
1559
|
+
help="Archive an executing run even if its execution ledger is missing or has unfinished tasks",
|
|
1560
|
+
)
|
|
1561
|
+
p.set_defaults(func=_cmd_run_archive)
|
|
1562
|
+
|
|
1563
|
+
# run-execute-start
|
|
1564
|
+
p = subparsers.add_parser("run-execute-start", help="Begin executing a closed run's PLAN in place")
|
|
1565
|
+
p.add_argument("--work-id", required=True)
|
|
1566
|
+
p.set_defaults(func=_cmd_run_execute_start)
|
|
1567
|
+
|
|
1370
1568
|
|
|
1371
1569
|
def _register_tier_commands(subparsers):
|
|
1372
1570
|
# tier-estimate
|