@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.
Files changed (28) 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 +295 -21
  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/atomic.py +6 -1
  21. package/tools/workflow_cli/cli.py +224 -21
  22. package/tools/workflow_cli/gates.py +149 -2
  23. package/tools/workflow_cli/install.py +49 -2
  24. package/tools/workflow_cli/markdown.py +18 -0
  25. package/tools/workflow_cli/models.py +19 -1
  26. package/tools/workflow_cli/stage_templates.py +2 -1
  27. package/tools/workflow_cli/version.py +1 -1
  28. 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}}"
@@ -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 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,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
- run_dir = _get_run_dir(work_id, base_path)
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 = _get_run_dir(work_id, args.base_path)
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
- 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
- )
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
- if source_record.status != RunStatus.CLOSED_AT_PLAN_CHECKPOINT:
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 CLOSED_AT_PLAN_CHECKPOINT "
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
- if not candidate_dir.exists():
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
- new_record.reopen_lineage = f"reopened_from: {source_id}@plan_checkpoint reason: {args.reason}"
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("run-reopen", help="Reopen a closed workflow run")
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 = _get_run_dir(work_id, args.base_path)
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),