@xenonbyte/req-2-plan 0.4.5 → 0.5.1
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 +295 -21
- 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/atomic.py +6 -1
- package/tools/workflow_cli/cli.py +224 -21
- package/tools/workflow_cli/gates.py +149 -2
- package/tools/workflow_cli/install.py +49 -2
- 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/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}}"
|
|
@@ -7,7 +7,12 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
|
|
9
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.
|
|
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
|
+
"""
|
|
11
16
|
tmp_path, fd = _open_unique_sibling_tmp(path)
|
|
12
17
|
try:
|
|
13
18
|
with os.fdopen(fd, "w", encoding=encoding) as tmp:
|
|
@@ -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,13 @@ 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.
|
|
74
|
-
|
|
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
|
+
run_dir = _reject_symlinked_run_paths(work_id, base_path)
|
|
75
88
|
mgr = RunStateManager(run_dir)
|
|
76
89
|
try:
|
|
77
90
|
return mgr.load(), mgr, run_dir
|
|
@@ -90,6 +103,32 @@ def _validate_work_id(raw: str) -> WorkId:
|
|
|
90
103
|
print_and_exit(format_error(str(e), exit_code=EXIT_CLI_ERR), EXIT_CLI_ERR)
|
|
91
104
|
|
|
92
105
|
|
|
106
|
+
def _ensure_workspace_gitignore_or_exit(base_path: Path) -> None:
|
|
107
|
+
try:
|
|
108
|
+
ensure_workspace_gitignore(base_path)
|
|
109
|
+
except ValueError as e:
|
|
110
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _reject_symlink_or_exit(path: Path, message: str) -> None:
|
|
114
|
+
if path.is_symlink():
|
|
115
|
+
print_and_exit(format_error(message, exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _reject_symlinked_run_paths(work_id, base_path: Path | None) -> Path:
|
|
119
|
+
"""Reject a symlinked workspace or run directory, then return the run dir.
|
|
120
|
+
|
|
121
|
+
Guards `.req-to-plan` and `.req-to-plan/<id>` up front (EXIT_CONFLICT) so no
|
|
122
|
+
command — read-only or mutating — ever follows either out of the workspace.
|
|
123
|
+
Call before any filesystem mutation that targets the run dir.
|
|
124
|
+
"""
|
|
125
|
+
root = base_path or Path.cwd()
|
|
126
|
+
_reject_symlink_or_exit(root / ".req-to-plan", "unsafe_workspace_dir_symlink")
|
|
127
|
+
run_dir = _get_run_dir(work_id, base_path)
|
|
128
|
+
_reject_symlink_or_exit(run_dir, f"Run directory is a symlink: {run_dir}")
|
|
129
|
+
return run_dir
|
|
130
|
+
|
|
131
|
+
|
|
93
132
|
def _validate_repo_path(raw: str) -> Path:
|
|
94
133
|
"""Return a repo path only when it is an existing directory."""
|
|
95
134
|
repo_path = Path(raw)
|
|
@@ -195,7 +234,7 @@ def _cmd_run_start(args):
|
|
|
195
234
|
EXIT_CLI_ERR,
|
|
196
235
|
)
|
|
197
236
|
repo_path = _validate_repo_path(args.repo_path) if args.repo_path else None
|
|
198
|
-
run_dir =
|
|
237
|
+
run_dir = _reject_symlinked_run_paths(work_id, args.base_path)
|
|
199
238
|
mgr = RunStateManager(run_dir)
|
|
200
239
|
|
|
201
240
|
run_dir_occupied = False
|
|
@@ -371,6 +410,8 @@ def _cmd_run_close(args):
|
|
|
371
410
|
),
|
|
372
411
|
EXIT_CONFLICT,
|
|
373
412
|
)
|
|
413
|
+
base = args.base_path or Path.cwd()
|
|
414
|
+
_ensure_workspace_gitignore_or_exit(base)
|
|
374
415
|
try:
|
|
375
416
|
record = update_run_status(record, RunStatus.CLOSED_AT_PLAN_CHECKPOINT)
|
|
376
417
|
except ValueError as e:
|
|
@@ -378,6 +419,13 @@ def _cmd_run_close(args):
|
|
|
378
419
|
record.current_stage = Stage.CLOSED
|
|
379
420
|
update_resume_context(record, last_operation="close_at_plan_checkpoint")
|
|
380
421
|
mgr.save(record)
|
|
422
|
+
# PLAN complete → land the requirement directory in version control
|
|
423
|
+
# (best-effort, path-limited; never touches unrelated changes). spec §4.5
|
|
424
|
+
commit_requirement_dir(
|
|
425
|
+
base,
|
|
426
|
+
str(record.work_id),
|
|
427
|
+
f"chore(r2p): plan {record.work_id}",
|
|
428
|
+
)
|
|
381
429
|
print_and_exit(
|
|
382
430
|
format_success({"work_id": str(record.work_id), "status": record.status.value}, message="Run closed"),
|
|
383
431
|
EXIT_OK,
|
|
@@ -388,22 +436,17 @@ def _cmd_run_reopen(args):
|
|
|
388
436
|
source_id = str(_validate_work_id(args.from_id))
|
|
389
437
|
target_stage = _parse_reopen_stage(args.stage)
|
|
390
438
|
|
|
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
|
-
)
|
|
439
|
+
# Load source run through the shared guard so reopen cannot follow a
|
|
440
|
+
# symlinked .req-to-plan/<work-id> outside the workspace.
|
|
441
|
+
source_record, source_mgr, source_dir = _load_run(source_id, args.base_path)
|
|
401
442
|
|
|
402
|
-
|
|
443
|
+
reopenable_statuses = {RunStatus.CLOSED_AT_PLAN_CHECKPOINT, RunStatus.EXECUTING}
|
|
444
|
+
if source_record.status not in reopenable_statuses:
|
|
403
445
|
print_and_exit(
|
|
404
446
|
format_error(
|
|
405
|
-
f"Source run {source_id!r} is not
|
|
406
|
-
f"(status={source_record.status.value!r})"
|
|
447
|
+
f"Source run {source_id!r} is not reopenable "
|
|
448
|
+
f"(status={source_record.status.value!r}); must be "
|
|
449
|
+
"closed_at_plan_checkpoint or executing",
|
|
407
450
|
exit_code=EXIT_CONFLICT,
|
|
408
451
|
),
|
|
409
452
|
EXIT_CONFLICT,
|
|
@@ -428,7 +471,8 @@ def _cmd_run_reopen(args):
|
|
|
428
471
|
except ValueError as e:
|
|
429
472
|
print_and_exit(format_error(str(e), exit_code=EXIT_CLI_ERR), EXIT_CLI_ERR)
|
|
430
473
|
candidate_dir = _get_run_dir(candidate, args.base_path)
|
|
431
|
-
|
|
474
|
+
candidate_archive_dir = (args.base_path or Path.cwd()) / ".req-to-plan" / "archive" / candidate
|
|
475
|
+
if not candidate_dir.exists() and not candidate_archive_dir.exists():
|
|
432
476
|
new_work_id = candidate_work_id
|
|
433
477
|
new_work_id_str = candidate
|
|
434
478
|
new_run_dir = candidate_dir
|
|
@@ -463,7 +507,12 @@ def _cmd_run_reopen(args):
|
|
|
463
507
|
new_record = create_run_record(new_work_id)
|
|
464
508
|
new_record.tier_estimate = source_record.tier_estimate
|
|
465
509
|
new_record.tier_locked = source_record.tier_locked
|
|
466
|
-
|
|
510
|
+
source_phase = (
|
|
511
|
+
"execution"
|
|
512
|
+
if source_record.status == RunStatus.EXECUTING
|
|
513
|
+
else "plan_checkpoint"
|
|
514
|
+
)
|
|
515
|
+
new_record.reopen_lineage = f"reopened_from: {source_id}@{source_phase} reason: {args.reason}"
|
|
467
516
|
new_record.current_stage = target_stage
|
|
468
517
|
|
|
469
518
|
# Copy approved checkpoints before target stage
|
|
@@ -476,6 +525,9 @@ def _cmd_run_reopen(args):
|
|
|
476
525
|
# Repopulate active_artifacts for copied stages so the reopened record
|
|
477
526
|
# matches the on-disk artifacts and approved checkpoints.
|
|
478
527
|
for cp in new_record.approved_checkpoints:
|
|
528
|
+
# Invariant: cp.artifact == STAGE_ARTIFACT_MAP[cp.stage] (artifacts are
|
|
529
|
+
# only ever created via the map, artifact.py:23). The existence check and
|
|
530
|
+
# the recorded name are therefore interchangeable; no drift path exists.
|
|
479
531
|
artifact_name = STAGE_ARTIFACT_MAP.get(cp.stage)
|
|
480
532
|
if artifact_name and (new_run_dir / artifact_name).exists():
|
|
481
533
|
upsert_active_artifact(
|
|
@@ -489,6 +541,19 @@ def _cmd_run_reopen(args):
|
|
|
489
541
|
new_mgr = RunStateManager(new_run_dir)
|
|
490
542
|
new_mgr.save(new_record)
|
|
491
543
|
|
|
544
|
+
if source_record.status == RunStatus.EXECUTING:
|
|
545
|
+
try:
|
|
546
|
+
source_record = update_run_status(source_record, RunStatus.CLOSED_AT_PLAN_CHECKPOINT)
|
|
547
|
+
except ValueError as e:
|
|
548
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
|
|
549
|
+
source_record.current_stage = Stage.CLOSED
|
|
550
|
+
update_resume_context(
|
|
551
|
+
source_record,
|
|
552
|
+
last_operation="reopen_from_execution",
|
|
553
|
+
next_operation=f"continue_reopened_run:{new_work_id}",
|
|
554
|
+
)
|
|
555
|
+
source_mgr.save(source_record)
|
|
556
|
+
|
|
492
557
|
print_and_exit(
|
|
493
558
|
format_success(
|
|
494
559
|
{
|
|
@@ -503,6 +568,126 @@ def _cmd_run_reopen(args):
|
|
|
503
568
|
)
|
|
504
569
|
|
|
505
570
|
|
|
571
|
+
def _cmd_run_archive(args):
|
|
572
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
573
|
+
base = args.base_path or Path.cwd()
|
|
574
|
+
archivable = {RunStatus.CLOSED_AT_PLAN_CHECKPOINT, RunStatus.EXECUTING}
|
|
575
|
+
if record.status not in archivable:
|
|
576
|
+
print_and_exit(
|
|
577
|
+
format_error(
|
|
578
|
+
f"Cannot archive run in status {record.status.value!r}; "
|
|
579
|
+
"must be closed_at_plan_checkpoint or executing",
|
|
580
|
+
exit_code=EXIT_CONFLICT,
|
|
581
|
+
),
|
|
582
|
+
EXIT_CONFLICT,
|
|
583
|
+
)
|
|
584
|
+
# 0. Completion gate: an executing run must show every PLAN-TASK done in its
|
|
585
|
+
# ledger before it can be archived. --force overrides (abandoned/superseded run).
|
|
586
|
+
if record.status == RunStatus.EXECUTING and not args.force:
|
|
587
|
+
gate = check_execution_complete(run_dir)
|
|
588
|
+
if not gate.passed:
|
|
589
|
+
detail = " ".join(gate.issues)
|
|
590
|
+
print_and_exit(
|
|
591
|
+
format_error(
|
|
592
|
+
f"Execution incomplete: {detail} "
|
|
593
|
+
"Re-run with --force to archive an unfinished run.",
|
|
594
|
+
exit_code=gate.exit_code,
|
|
595
|
+
),
|
|
596
|
+
gate.exit_code,
|
|
597
|
+
)
|
|
598
|
+
# 1. Refuse to clobber an existing archived copy before mutating state.
|
|
599
|
+
archive_dir = base / ".req-to-plan" / "archive" / str(record.work_id)
|
|
600
|
+
if archive_dir.exists():
|
|
601
|
+
print_and_exit(
|
|
602
|
+
format_error(
|
|
603
|
+
f"Archive target already exists: {archive_dir}",
|
|
604
|
+
exit_code=EXIT_CONFLICT,
|
|
605
|
+
),
|
|
606
|
+
EXIT_CONFLICT,
|
|
607
|
+
)
|
|
608
|
+
# 2. Ensure /archive is ignored before anything lands under it.
|
|
609
|
+
_ensure_workspace_gitignore_or_exit(base)
|
|
610
|
+
# 3. Build the archived record, but do not persist it until the move succeeds.
|
|
611
|
+
try:
|
|
612
|
+
archived_record = update_run_status(record, RunStatus.ARCHIVED)
|
|
613
|
+
except ValueError as e:
|
|
614
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
|
|
615
|
+
_reject_symlink_or_exit(archive_dir.parent, f"Archive parent is a symlink: {archive_dir.parent}")
|
|
616
|
+
archive_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
617
|
+
# 4. Move the run dir into the (ignored) archive, then persist ARCHIVED there.
|
|
618
|
+
shutil.move(str(run_dir), str(archive_dir))
|
|
619
|
+
try:
|
|
620
|
+
RunStateManager(archive_dir).save(archived_record)
|
|
621
|
+
except Exception:
|
|
622
|
+
if archive_dir.exists() and not run_dir.exists():
|
|
623
|
+
shutil.move(str(archive_dir), str(run_dir))
|
|
624
|
+
raise
|
|
625
|
+
# 5. Commit the removal of the original path (untracks the dir). spec §4.6
|
|
626
|
+
commit_requirement_dir(
|
|
627
|
+
base, str(archived_record.work_id), f"chore(r2p): archive {archived_record.work_id}"
|
|
628
|
+
)
|
|
629
|
+
print_and_exit(
|
|
630
|
+
format_success(
|
|
631
|
+
{"work_id": str(archived_record.work_id), "status": "archived", "archived_to": str(archive_dir)},
|
|
632
|
+
message=f"Run archived: {archived_record.work_id}",
|
|
633
|
+
),
|
|
634
|
+
EXIT_OK,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _cmd_run_execute_start(args):
|
|
639
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
640
|
+
if record.status != RunStatus.CLOSED_AT_PLAN_CHECKPOINT:
|
|
641
|
+
print_and_exit(
|
|
642
|
+
format_error(
|
|
643
|
+
f"Cannot start execution in status {record.status.value!r}; "
|
|
644
|
+
"must be closed_at_plan_checkpoint (plan_not_ready)",
|
|
645
|
+
exit_code=EXIT_CONFLICT,
|
|
646
|
+
),
|
|
647
|
+
EXIT_CONFLICT,
|
|
648
|
+
)
|
|
649
|
+
try:
|
|
650
|
+
plan_text = read_artifact(run_dir, Stage.PLAN)
|
|
651
|
+
except FileNotFoundError:
|
|
652
|
+
print_and_exit(
|
|
653
|
+
format_error("PLAN artifact not found; cannot start execution", exit_code=EXIT_NOT_FOUND),
|
|
654
|
+
EXIT_NOT_FOUND,
|
|
655
|
+
)
|
|
656
|
+
# Seed the structural progress ledger (IDs + checkboxes = structure, not
|
|
657
|
+
# semantics; the agent appends progress). CLI never generates artifact text.
|
|
658
|
+
anchors = plan_task_anchors(strip_readonly_sections(plan_text))
|
|
659
|
+
if not anchors:
|
|
660
|
+
print_and_exit(
|
|
661
|
+
format_error(
|
|
662
|
+
"PLAN contains no PLAN-TASK anchors; repair PLAN with "
|
|
663
|
+
"### PLAN-TASK-NNN headings before execution",
|
|
664
|
+
exit_code=EXIT_CONFLICT,
|
|
665
|
+
),
|
|
666
|
+
EXIT_CONFLICT,
|
|
667
|
+
)
|
|
668
|
+
lines = ["# Execution Progress", "", f"work_id: {record.work_id}", ""]
|
|
669
|
+
lines += [f"- [ ] {tid} {title}".rstrip() for tid, title in anchors]
|
|
670
|
+
exec_dir = run_dir / "execution"
|
|
671
|
+
_reject_symlink_or_exit(exec_dir, "unsafe_execution_dir_symlink")
|
|
672
|
+
exec_dir.mkdir(parents=True, exist_ok=True)
|
|
673
|
+
atomic_write_text(exec_dir / "progress.md", "\n".join(lines) + "\n")
|
|
674
|
+
record = update_run_status(record, RunStatus.EXECUTING)
|
|
675
|
+
update_resume_context(record, last_operation="execute_start", next_operation="implement_tasks")
|
|
676
|
+
mgr.save(record)
|
|
677
|
+
print_and_exit(
|
|
678
|
+
format_success(
|
|
679
|
+
{
|
|
680
|
+
"work_id": str(record.work_id),
|
|
681
|
+
"status": record.status.value,
|
|
682
|
+
"ledger": str(exec_dir / "progress.md"),
|
|
683
|
+
"task_count": len(anchors),
|
|
684
|
+
},
|
|
685
|
+
message=f"Execution started: {record.work_id}",
|
|
686
|
+
),
|
|
687
|
+
EXIT_OK,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
506
691
|
def _cmd_gap_resolve(args):
|
|
507
692
|
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
508
693
|
route = next(
|
|
@@ -1367,12 +1552,30 @@ def _register_run_commands(subparsers):
|
|
|
1367
1552
|
p.set_defaults(func=_cmd_run_close)
|
|
1368
1553
|
|
|
1369
1554
|
# run-reopen
|
|
1370
|
-
p = subparsers.add_parser(
|
|
1555
|
+
p = subparsers.add_parser(
|
|
1556
|
+
"run-reopen",
|
|
1557
|
+
help="Reopen a closed or executing workflow run",
|
|
1558
|
+
)
|
|
1371
1559
|
p.add_argument("--from", dest="from_id", required=True, help="Source work-id to reopen from")
|
|
1372
1560
|
p.add_argument("--stage", required=True, help="Target stage to reopen at")
|
|
1373
1561
|
p.add_argument("--reason", required=True, help="Reason for reopening")
|
|
1374
1562
|
p.set_defaults(func=_cmd_run_reopen)
|
|
1375
1563
|
|
|
1564
|
+
# run-archive
|
|
1565
|
+
p = subparsers.add_parser("run-archive", help="Archive a closed run out of the active workspace")
|
|
1566
|
+
p.add_argument("--work-id", required=True)
|
|
1567
|
+
p.add_argument(
|
|
1568
|
+
"--force",
|
|
1569
|
+
action="store_true",
|
|
1570
|
+
help="Archive an executing run even if its execution ledger is missing or has unfinished tasks",
|
|
1571
|
+
)
|
|
1572
|
+
p.set_defaults(func=_cmd_run_archive)
|
|
1573
|
+
|
|
1574
|
+
# run-execute-start
|
|
1575
|
+
p = subparsers.add_parser("run-execute-start", help="Begin executing a closed run's PLAN in place")
|
|
1576
|
+
p.add_argument("--work-id", required=True)
|
|
1577
|
+
p.set_defaults(func=_cmd_run_execute_start)
|
|
1578
|
+
|
|
1376
1579
|
|
|
1377
1580
|
def _register_tier_commands(subparsers):
|
|
1378
1581
|
# tier-estimate
|
|
@@ -1808,7 +2011,7 @@ def _cmd_context_build(args):
|
|
|
1808
2011
|
from tools.workflow_cli.context_pack import build_context_pack, write_context_pack
|
|
1809
2012
|
|
|
1810
2013
|
work_id = str(_validate_work_id(args.work_id))
|
|
1811
|
-
run_dir =
|
|
2014
|
+
run_dir = _reject_symlinked_run_paths(work_id, args.base_path)
|
|
1812
2015
|
if not run_dir.exists():
|
|
1813
2016
|
print_and_exit(
|
|
1814
2017
|
format_error(f"run not found: {work_id}", exit_code=EXIT_NOT_FOUND),
|