@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.
Files changed (32) hide show
  1. package/README.md +170 -125
  2. package/README.zh-CN.md +154 -108
  3. package/package.json +1 -1
  4. package/tools/r2p-archive +10 -0
  5. package/tools/r2p-execute +10 -0
  6. package/tools/workflow_cli/agent_shortcuts.py +310 -26
  7. package/tools/workflow_cli/agent_templates/claude/SKILL.md +2 -1
  8. package/tools/workflow_cli/agent_templates/claude/commands/r2p-archive.md +10 -0
  9. package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +1 -0
  10. package/tools/workflow_cli/agent_templates/claude/commands/r2p-execute.md +122 -0
  11. package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +2 -2
  12. package/tools/workflow_cli/agent_templates/codex/skills/r2p-archive/SKILL.md +14 -0
  13. package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +2 -0
  14. package/tools/workflow_cli/agent_templates/codex/skills/r2p-execute/SKILL.md +123 -0
  15. package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +2 -2
  16. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-archive.toml +4 -0
  17. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +1 -1
  18. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-execute.toml +4 -0
  19. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +1 -1
  20. package/tools/workflow_cli/artifact.py +5 -2
  21. package/tools/workflow_cli/atomic.py +50 -0
  22. package/tools/workflow_cli/cli.py +229 -31
  23. package/tools/workflow_cli/gates.py +149 -2
  24. package/tools/workflow_cli/install.py +56 -3
  25. package/tools/workflow_cli/link_expander.py +9 -25
  26. package/tools/workflow_cli/markdown.py +18 -0
  27. package/tools/workflow_cli/models.py +19 -1
  28. package/tools/workflow_cli/stage_templates.py +2 -1
  29. package/tools/workflow_cli/state.py +5 -6
  30. package/tools/workflow_cli/tier.py +1 -1
  31. package/tools/workflow_cli/version.py +1 -1
  32. 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
 
@@ -0,0 +1,4 @@
1
+ name = "r2p-archive"
2
+ description = "Archive a closed or executing workflow run out of the active workspace"
3
+ command = "{{R2P_BIN_DIR}}/r2p-archive"
4
+ version = "{{R2P_VERSION}}"
@@ -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}}"
@@ -1,4 +1,4 @@
1
1
  name = "r2p-reopen"
2
- description = "Reopen a closed workflow run from a specific stage"
2
+ description = "Reopen a closed or executing workflow run from a specific stage and select the reopened run"
3
3
  command = "{{R2P_BIN_DIR}}/r2p-reopen"
4
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
- path.write_text(full_text, encoding="utf-8")
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.write_text(content, encoding="utf-8")
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 check_entry_gate, check_quality_gate, check_forced_subagent_review
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 is recorded as not-expanded (needs confirmation).
235
- link_results = expand_links(requirement, base_path=repo_path, fetch_urls=False)
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
- source_dir = _get_run_dir(source_id, args.base_path)
393
- source_mgr = RunStateManager(source_dir)
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
- if source_record.status != RunStatus.CLOSED_AT_PLAN_CHECKPOINT:
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 CLOSED_AT_PLAN_CHECKPOINT "
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
- if not candidate_dir.exists():
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
- new_record.reopen_lineage = f"reopened_from: {source_id}@plan_checkpoint reason: {args.reason}"
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 high_modifiers:
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("run-reopen", help="Reopen a closed workflow run")
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