@xenonbyte/req-2-plan 0.5.2 → 0.6.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 CHANGED
@@ -43,7 +43,7 @@ important behavior, or when you want a durable handoff between agents.
43
43
  - **Four supported platforms**: installs matching surfaces for Claude Code (`claude`), Codex (`codex`), Gemini (`gemini`), and opencode (`opencode`).
44
44
  - **One lifecycle CLI**: `r2p install`, `r2p uninstall`, `r2p status`, `r2p version`, and `r2p help`.
45
45
  - **Manifest-backed install safety**: pre-existing files are backed up, and uninstall removes only managed paths.
46
- - **Project Context Pack**: `--repo-path` captures real repository facts for tiering and PLAN checks.
46
+ - **Project Context Pack**: real repository facts (the current directory by default, or `--repo-path <dir>`) ground tiering and PLAN checks.
47
47
  - **Repair paths**: reopen closed runs, route upstream gaps, and resolve repaired decisions.
48
48
  - **Execution handoff**: `r2p-execute` can drive an approved PLAN through an in-place implementation loop.
49
49
 
@@ -109,20 +109,19 @@ r2p install --platform claude,codex,gemini,opencode
109
109
  Install the platform skills, then start a workflow from your agent:
110
110
 
111
111
  ```text
112
- /r2p-start --repo-path . "Add rate limiting"
112
+ /r2p-start "Add rate limiting"
113
113
  /r2p-continue
114
114
  ```
115
115
 
116
116
  Start from a requirement file instead of inline text:
117
117
 
118
118
  ```text
119
- /r2p-start --repo-path . --file change-req.md
119
+ /r2p-start --file change-req.md
120
120
  ```
121
121
 
122
- For repositories used as requirement context, pass `--repo-path`. Use `.` for the
123
- current repository or a path to the target repository for cross-project work.
124
- This builds the Project Context Pack used by tier estimation and PLAN reference
125
- checks.
122
+ Tier estimation and the Project Context Pack are grounded in the current
123
+ directory by default. Pass `--repo-path <dir>` to ground them in a different
124
+ repository instead - for example, a target repository for cross-project work.
126
125
 
127
126
  The workflow stops whenever it needs a human or agent action: tier lock,
128
127
  artifact content, quality-gate repair, checkpoint approval, subagent review, or
package/README.zh-CN.md CHANGED
@@ -38,7 +38,7 @@ AI agent 执行很快,但模糊需求容易变成含糊计划、隐藏范围
38
38
  - **支持 4 个平台**:为 Claude Code(`claude`)、Codex(`codex`)、Gemini(`gemini`)、opencode(`opencode`)安装匹配入口。
39
39
  - **单一生命周期 CLI**:`r2p install`、`r2p uninstall`、`r2p status`、`r2p version`、`r2p help`。
40
40
  - **Manifest-backed 安装安全**:覆盖前备份已存在文件,卸载只删除受管路径。
41
- - **Project Context Pack**:`--repo-path` 捕获真实仓库事实,用于 tier 估算和 PLAN 校验。
41
+ - **Project Context Pack**:以真实仓库事实(默认当前目录,或用 `--repo-path <dir>`)支撑 tier 估算和 PLAN 校验。
42
42
  - **修复路径**:可重开 closed run、路由上游缺口,并关闭已修复的决策路线。
43
43
  - **执行交接**:`r2p-execute` 可以把获批 PLAN 接入当前分支上的实现循环。
44
44
 
@@ -101,18 +101,18 @@ r2p install --platform claude,codex,gemini,opencode
101
101
  安装平台 skill 后,在 agent 里启动一次工作流:
102
102
 
103
103
  ```text
104
- /r2p-start --repo-path . "Add rate limiting"
104
+ /r2p-start "Add rate limiting"
105
105
  /r2p-continue
106
106
  ```
107
107
 
108
108
  也可以从需求文件启动,而不是传内联文本:
109
109
 
110
110
  ```text
111
- /r2p-start --repo-path . --file change-req.md
111
+ /r2p-start --file change-req.md
112
112
  ```
113
113
 
114
- 只要需求以代码仓库为上下文,就传 `--repo-path`。当前仓库传 `.`,跨项目需求传目标仓库路径。
115
- 这会构建 Project Context Pack,供 tier 估算和 PLAN 引用校验使用。
114
+ tier 估算和 Project Context Pack 默认以当前目录为基准。传 `--repo-path <dir>`
115
+ 可改为以另一个仓库为基准——例如跨项目需求里的目标仓库。
116
116
 
117
117
  工作流会在需要人或 agent 动作时停下:锁定 tier、填写 artifact、修复 quality gate、
118
118
  批准 checkpoint、执行 subagent review,或解决 gap。按输出里的 `next:` 命令执行,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenonbyte/req-2-plan",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "Requirement-to-PLAN workflow CLI and agent integration installer.",
5
5
  "bin": {
6
6
  "r2p": "bin/r2p.js"
@@ -31,6 +31,15 @@ Before dispatching Task 1, read `07-plan.md` once and scan for:
31
31
  Batch all findings into one question to the human **before** execution begins. If the scan is clean, proceed without comment.
32
32
  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.
33
33
 
34
+ ## Model Selection
35
+
36
+ Use the least powerful model that can handle each role:
37
+ - **Mechanical implementation** (isolated, clear spec, complete `Skeleton`, 1–2 files): fast/cheap model.
38
+ - **Integration / judgment / debugging** (multi-file coordination, pattern matching): standard model.
39
+ - **Architecture / design AND the final whole-branch review**: most capable model.
40
+ - Always specify the model explicitly when dispatching; an omitted model inherits the session model.
41
+ - **Turn count beats token price**: use a mid-tier floor for reviewers and for implementers working from prose descriptions; drop to cheapest only for complete-code/single-file mechanical tasks.
42
+
34
43
  ## Per-Task Loop
35
44
 
36
45
  For each PLAN-TASK (in order):
@@ -41,6 +50,8 @@ Read the task text directly from `07-plan.md`. Note the task's `Skeleton`, `Step
41
50
 
42
51
  ### 2. Dispatch a fresh implementer subagent
43
52
 
53
+ Record BASE (`git rev-parse HEAD`) BEFORE dispatching the implementer — **never use `HEAD~1`** as BASE (it drops all but the last commit of a multi-commit task). For Task 1, this BASE is also `<execution-base-commit>` for the final whole-branch review. Persist the Task 1 BASE immediately in tracked execution state by adding `Execution BASE: <execution-base-commit>` to `execution/progress.md`.
54
+
44
55
  Provide the subagent with:
45
56
  - The task text (from `07-plan.md`)
46
57
  - Scene-setting context (project, dependencies, architectural constraints)
@@ -68,12 +79,12 @@ The fresh implementer subagent verifies-then-removes ambiguity by evidence and T
68
79
  ### 5. Write diff and dispatch task-reviewer
69
80
 
70
81
  After the implementer reports DONE:
71
- 1. Record the diff inline: `git diff -U10 <base-commit> HEAD` (no external script needed)
82
+ 1. `mkdir -p .req-to-plan/<work-id>/logs` then `git diff -U10 <base-commit> HEAD > .req-to-plan/<work-id>/logs/task-N-diff.md`. Keep diff scratch under `logs/` (gitignored), never under `execution/`.
72
83
  2. Dispatch a task-reviewer subagent with:
73
84
  - The task text and `Spec References` from `07-plan.md`
74
85
  - The implementer's report
75
- - The diff
76
- - Global constraints from the plan
86
+ - The diff file path (`.req-to-plan/<work-id>/logs/task-N-diff.md`)
87
+ - Global constraints from the plan (copy verbatim from `## Global Constraints`); never pre-judge a finding's severity; never paste prior-task summaries into a later dispatch
77
88
 
78
89
  The task-reviewer returns two verdicts:
79
90
  - **Spec compliance**: checked against `Spec References` + `Verification`
@@ -86,14 +97,27 @@ The task-reviewer returns two verdicts:
86
97
  - 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:
87
98
  `Task N: complete (commits <base7>..<head7>, review clean)`
88
99
 
100
+ **Continuous execution**: execute all PLAN-TASKs without pausing to ask "should I continue?" between tasks. Stop only on: unresolvable `BLOCKED`, upstream defect requiring repair, dirty-tree block, or all tasks complete. `Verification` requires fresh command output; "should pass" / "looks correct" is not evidence; do not report `DONE` without it.
101
+
89
102
  ## Final Whole-Branch Review
90
103
 
91
- After all tasks complete, dispatch a final whole-branch review subagent:
92
- - Scope: all commits since the branch started (or since `closed_at_plan_checkpoint`)
93
- - Include the diff (`git diff -U10 <merge-base> HEAD`)
94
- - Dispatch fix subagents for any Critical/Important findings before marking done
104
+ After all tasks complete, dispatch a final whole-branch review subagent on the **most capable model**:
105
+ - First create the whole-branch diff: `mkdir -p .req-to-plan/<work-id>/logs` then `git diff -U10 <execution-base-commit> HEAD > .req-to-plan/<work-id>/logs/final-diff.md`
106
+ - Scope: review the complete execution range `git diff -U10 <execution-base-commit> HEAD`, where `<execution-base-commit>` is the Task 1 BASE captured before dispatching the first implementer
107
+ - Include the diff file path (`.req-to-plan/<work-id>/logs/final-diff.md`) in the reviewer dispatch; do not ask the reviewer to infer the changed range
108
+ - **re-run the full verification suite** on the final HEAD and attach the fresh output (per-task greens do not catch cross-task regressions)
109
+ - Walk the PLAN task-by-task as a line-by-line requirements checklist; report any gap
110
+ - Dispatch ONE fix subagent carrying the complete findings list (not one fixer per finding)
95
111
  - This whole-branch review is the merge gate
96
112
 
113
+ After the review settles, write `execution/final-review.md` recording the reviewed range, a one-line summary, and the verdict:
114
+ - `Verdict: Approved` when the review is clean
115
+ - `Verdict: Changes Requested` while findings remain
116
+ - After any final-review fix wave, regenerate `.req-to-plan/<work-id>/logs/final-diff.md` from the same `<execution-base-commit>` to current `HEAD`, re-run the full verification suite, and re-dispatch the final whole-branch reviewer with the refreshed diff and output
117
+ - Repeat until the post-fix reviewer is clean; only then append `Verdict: Approved` as the final unfenced verdict (the gate reads the last one)
118
+
119
+ Note: `r2p-archive` refuses to archive an executing run unless this file's current verdict is `Verdict: Approved`.
120
+
97
121
  ## Auto-Archive on Completion
98
122
 
99
123
  When all tasks are done and the final whole-branch review is clean, call:
@@ -109,6 +133,7 @@ Commits are already on the **current branch**. `push` and PR creation still requ
109
133
  ## Durable Progress
110
134
 
111
135
  Track progress in `execution/progress.md` (not only in todos). On resume, read the ledger and skip tasks already marked complete.
136
+ On resume, read `execution/progress.md` before the final review and reuse its `Execution BASE:` line as `<execution-base-commit>`. Do not recalculate it from `HEAD` or from the latest task range. If the line is missing, stop and ask the human for the original Task 1 BASE instead of inferring a range.
112
137
 
113
138
  ## Error Reference
114
139
 
@@ -9,4 +9,4 @@ To start from a requirement document, pass `--file <path>` instead of inline tex
9
9
 
10
10
  Use `--separate` to create an independent run when another open run exists.
11
11
 
12
- Optionally pass `--repo-path <dir>` to ground tier estimation and the Project Context Pack in real repo facts.
12
+ Tier estimation and the Project Context Pack are grounded in the current directory by default; pass `--repo-path <dir>` to ground them in a different repository instead.
@@ -32,6 +32,15 @@ Before dispatching Task 1, read `07-plan.md` once and scan for:
32
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
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
34
 
35
+ ## Model Selection
36
+
37
+ Use the least powerful model that can handle each role:
38
+ - **Mechanical implementation** (isolated, clear spec, complete `Skeleton`, 1–2 files): fast/cheap model.
39
+ - **Integration / judgment / debugging** (multi-file coordination, pattern matching): standard model.
40
+ - **Architecture / design AND the final whole-branch review**: most capable model.
41
+ - Always specify the model explicitly when dispatching; an omitted model inherits the session model.
42
+ - **Turn count beats token price**: use a mid-tier floor for reviewers and for implementers working from prose descriptions; drop to cheapest only for complete-code/single-file mechanical tasks.
43
+
35
44
  ## Per-Task Loop
36
45
 
37
46
  For each PLAN-TASK (in order):
@@ -42,6 +51,8 @@ Read the task text directly from `07-plan.md`. Note the task's `Skeleton`, `Step
42
51
 
43
52
  ### 2. Dispatch a fresh implementer subagent
44
53
 
54
+ Record BASE (`git rev-parse HEAD`) BEFORE dispatching the implementer — **never use `HEAD~1`** as BASE (it drops all but the last commit of a multi-commit task). For Task 1, this BASE is also `<execution-base-commit>` for the final whole-branch review. Persist the Task 1 BASE immediately in tracked execution state by adding `Execution BASE: <execution-base-commit>` to `execution/progress.md`.
55
+
45
56
  Provide the subagent with:
46
57
  - The task text (from `07-plan.md`)
47
58
  - Scene-setting context (project, dependencies, architectural constraints)
@@ -69,12 +80,12 @@ The fresh implementer subagent verifies-then-removes ambiguity by evidence and T
69
80
  ### 5. Write diff and dispatch task-reviewer
70
81
 
71
82
  After the implementer reports DONE:
72
- 1. Record the diff inline: `git diff -U10 <base-commit> HEAD`
83
+ 1. `mkdir -p .req-to-plan/<work-id>/logs` then `git diff -U10 <base-commit> HEAD > .req-to-plan/<work-id>/logs/task-N-diff.md`. Keep diff scratch under `logs/` (gitignored), never under `execution/`.
73
84
  2. Dispatch a task-reviewer subagent with:
74
85
  - The task text and `Spec References` from `07-plan.md`
75
86
  - The implementer's report
76
- - The diff
77
- - Global constraints from the plan
87
+ - The diff file path (`.req-to-plan/<work-id>/logs/task-N-diff.md`)
88
+ - Global constraints from the plan (copy verbatim from `## Global Constraints`); never pre-judge a finding's severity; never paste prior-task summaries into a later dispatch
78
89
 
79
90
  The task-reviewer returns two verdicts:
80
91
  - **Spec compliance**: checked against `Spec References` + `Verification`
@@ -87,13 +98,26 @@ The task-reviewer returns two verdicts:
87
98
  - 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
99
  `Task N: complete (commits <base7>..<head7>, review clean)`
89
100
 
101
+ **Continuous execution**: execute all PLAN-TASKs without pausing to ask "should I continue?" between tasks. Stop only on: unresolvable `BLOCKED`, upstream defect requiring repair, dirty-tree block, or all tasks complete. `Verification` requires fresh command output; "should pass" / "looks correct" is not evidence; do not report `DONE` without it.
102
+
90
103
  ## Final Whole-Branch Review
91
104
 
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`)
105
+ After all tasks complete, dispatch a final whole-branch review subagent on the **most capable model**:
106
+ - First create the whole-branch diff: `mkdir -p .req-to-plan/<work-id>/logs` then `git diff -U10 <execution-base-commit> HEAD > .req-to-plan/<work-id>/logs/final-diff.md`
107
+ - Scope: review the complete execution range `git diff -U10 <execution-base-commit> HEAD`, where `<execution-base-commit>` is the Task 1 BASE captured before dispatching the first implementer
108
+ - Include the diff file path (`.req-to-plan/<work-id>/logs/final-diff.md`) in the reviewer dispatch; do not ask the reviewer to infer the changed range
109
+ - **re-run the full verification suite** on the final HEAD and attach the fresh output (per-task greens do not catch cross-task regressions)
110
+ - Walk the PLAN task-by-task as a line-by-line requirements checklist; report any gap
111
+ - Dispatch ONE fix subagent carrying the complete findings list (not one fixer per finding)
95
112
  - This whole-branch review is the merge gate
96
- - Dispatch fix subagents for any Critical/Important findings before marking done
113
+
114
+ After the review settles, write `execution/final-review.md` recording the reviewed range, a one-line summary, and the verdict:
115
+ - `Verdict: Approved` when the review is clean
116
+ - `Verdict: Changes Requested` while findings remain
117
+ - After any final-review fix wave, regenerate `.req-to-plan/<work-id>/logs/final-diff.md` from the same `<execution-base-commit>` to current `HEAD`, re-run the full verification suite, and re-dispatch the final whole-branch reviewer with the refreshed diff and output
118
+ - Repeat until the post-fix reviewer is clean; only then append `Verdict: Approved` as the final unfenced verdict (the gate reads the last one)
119
+
120
+ Note: `r2p-archive` refuses to archive an executing run unless this file's current verdict is `Verdict: Approved`.
97
121
 
98
122
  ## Auto-Archive on Completion
99
123
 
@@ -110,6 +134,7 @@ Commits are already on the **current branch**. `push` and PR creation still requ
110
134
  ## Durable Progress
111
135
 
112
136
  Track progress in `execution/progress.md` (not only in todos). On resume, read the ledger and skip tasks already marked complete.
137
+ On resume, read `execution/progress.md` before the final review and reuse its `Execution BASE:` line as `<execution-base-commit>`. Do not recalculate it from `HEAD` or from the latest task range. If the line is missing, stop and ask the human for the original Task 1 BASE instead of inferring a range.
113
138
 
114
139
  ## Error Reference
115
140
 
@@ -13,4 +13,4 @@ To start from a requirement document, pass `--file <path>` instead of inline tex
13
13
 
14
14
  Use `--separate` to create an independent run when another open run exists.
15
15
 
16
- Optionally pass `--repo-path <dir>` to ground tier estimation and the Project Context Pack in real repo facts.
16
+ Tier estimation and the Project Context Pack are grounded in the current directory by default; pass `--repo-path <dir>` to ground them in a different repository instead.
@@ -1,4 +1,4 @@
1
1
  name = "r2p-start"
2
- description = "Start a new requirement-to-PLAN workflow run. Optionally pass --repo-path <dir> to ground tier estimation and the Project Context Pack in real repo facts."
2
+ description = "Start a new requirement-to-PLAN workflow run. Tier estimation and the Project Context Pack are grounded in the current directory by default; pass --repo-path <dir> to ground them in a different repository instead."
3
3
  command = "{{R2P_BIN_DIR}}/r2p-start"
4
4
  version = "{{R2P_VERSION}}"
@@ -47,6 +47,7 @@ from tools.workflow_cli.gates import (
47
47
  check_quality_gate,
48
48
  check_forced_subagent_review,
49
49
  check_execution_complete,
50
+ check_final_review_recorded,
50
51
  )
51
52
  from tools.workflow_cli.output import (
52
53
  COMPACT_DETAIL_LIMIT,
@@ -284,7 +285,16 @@ def _cmd_run_start(args):
284
285
  format_error("Requirement must not be blank", exit_code=EXIT_CLI_ERR),
285
286
  EXIT_CLI_ERR,
286
287
  )
287
- repo_path = _validate_repo_path(args.repo_path) if args.repo_path else None
288
+ if args.repo_path:
289
+ repo_path = _validate_repo_path(args.repo_path)
290
+ else:
291
+ # --repo-path is optional: default to the workspace root (--base-path,
292
+ # which itself defaults to the current directory) so tier estimation and
293
+ # the Project Context Pack are grounded in real repo facts without an
294
+ # explicit flag. A standard-tier PLAN later requires a usable Context Pack
295
+ # (R11), so grounding by default avoids a silent gate failure. Using
296
+ # base_path (not literal Path.cwd()) preserves --base-path test isolation.
297
+ repo_path = args.base_path or Path.cwd()
288
298
  run_dir = _reject_symlinked_run_paths(work_id, args.base_path)
289
299
  mgr = RunStateManager(run_dir)
290
300
 
@@ -613,7 +623,13 @@ def _cmd_run_reopen(args):
613
623
  last_operation="reopen_from_execution",
614
624
  next_operation=f"continue_reopened_run:{new_work_id}",
615
625
  )
616
- source_mgr.save(source_record)
626
+ try:
627
+ source_mgr.save(source_record)
628
+ except Exception:
629
+ # Roll back the just-created new run so no orphan is left and the
630
+ # source stays consistently EXECUTING.
631
+ shutil.rmtree(new_run_dir, ignore_errors=True)
632
+ raise
617
633
 
618
634
  print_and_exit(
619
635
  format_success(
@@ -656,6 +672,17 @@ def _cmd_run_archive(args):
656
672
  ),
657
673
  gate.exit_code,
658
674
  )
675
+ # 0b. Final-review gate: the whole-branch review verdict must be recorded.
676
+ # --force bypasses (abandoned/superseded run already skips this block).
677
+ review_gate = check_final_review_recorded(run_dir)
678
+ if not review_gate.passed:
679
+ print_and_exit(
680
+ format_error(
681
+ " ".join(review_gate.issues),
682
+ exit_code=review_gate.exit_code,
683
+ ),
684
+ review_gate.exit_code,
685
+ )
659
686
  # 1. Refuse to clobber an existing archived copy before mutating state.
660
687
  archive_dir = base / ".req-to-plan" / "archive" / str(record.work_id)
661
688
  if archive_dir.exists():
@@ -909,9 +936,9 @@ def _cmd_gap_open(args):
909
936
  )
910
937
  mgr.save(record)
911
938
  except Exception as e:
912
- run_md_path.write_text(run_md_before, encoding="utf-8")
939
+ atomic_write_text(run_md_path, run_md_before)
913
940
  for _d, _aa, _artifact_file, artifact_path, artifact_before in reversed(affected):
914
- artifact_path.write_text(artifact_before, encoding="utf-8")
941
+ atomic_write_text(artifact_path, artifact_before)
915
942
  print_and_exit(
916
943
  format_error(
917
944
  f"Cannot gap-open: failed to mark downstream stale atomically ({e})",
@@ -1614,7 +1641,11 @@ def _register_run_commands(subparsers):
1614
1641
  default=None,
1615
1642
  help="Path to a file whose contents are the raw requirement",
1616
1643
  )
1617
- p.add_argument("--repo-path", default=None, help="Path to repository for baseline scan")
1644
+ p.add_argument(
1645
+ "--repo-path",
1646
+ default=None,
1647
+ help="Path to repository for baseline scan (default: current directory / --base-path)",
1648
+ )
1618
1649
  p.add_argument("--overwrite", action="store_true", help="Overwrite an existing run")
1619
1650
  p.set_defaults(func=_cmd_run_start)
1620
1651
 
@@ -11,6 +11,7 @@ try:
11
11
  except ImportError: # pragma: no cover - older interpreters: pyproject parsing degrades to a no-op
12
12
  tomllib = None
13
13
 
14
+ from tools.workflow_cli.atomic import atomic_write_text
14
15
  from tools.workflow_cli.repo_baseline import SKIP_DIRS, scan_repo_baseline
15
16
 
16
17
  _CONFIG_NAMES = {
@@ -167,6 +168,9 @@ def to_markdown(pack: ProjectContextPack) -> str:
167
168
  def write_context_pack(pack: ProjectContextPack, run_dir: Path) -> tuple[Path, Path]:
168
169
  json_path = run_dir / "02-project-context.json"
169
170
  md_path = run_dir / "02-project-context.md"
170
- json_path.write_text(to_json(pack), encoding="utf-8")
171
- md_path.write_text(to_markdown(pack), encoding="utf-8")
171
+ for target in (json_path, md_path):
172
+ if target.is_symlink():
173
+ raise ValueError(f"refusing to write through symlink: {target}")
174
+ atomic_write_text(json_path, to_json(pack))
175
+ atomic_write_text(md_path, to_markdown(pack))
172
176
  return md_path, json_path
@@ -1110,6 +1110,90 @@ def check_forced_subagent_review(
1110
1110
  )
1111
1111
 
1112
1112
 
1113
+ # ---------------------------------------------------------------------------
1114
+ # Final Review Presence Gate
1115
+ # ---------------------------------------------------------------------------
1116
+
1117
+ _FINAL_REVIEW_DISCLAIMER = (
1118
+ "Presence check on the review audit trail — not a correctness guarantee."
1119
+ )
1120
+ _SUPPORTED_VERDICTS = {"approved", "changes requested"}
1121
+
1122
+ _CANONICAL_NOT_APPROVED_MSG = (
1123
+ "Final whole-branch review not approved: execution/final-review.md is missing, "
1124
+ "or its current (last unfenced) 'Verdict:' line is not 'Approved'. "
1125
+ + _FINAL_REVIEW_DISCLAIMER
1126
+ + " Record 'Verdict: Approved', or re-run with --force to archive an abandoned run."
1127
+ )
1128
+
1129
+
1130
+ def _fail_gate(msg: str) -> GateResult:
1131
+ return GateResult(passed=False, issues=[msg], exit_code=EXIT_GATE_FAIL)
1132
+
1133
+
1134
+ def _current_verdict(text: str) -> str | None:
1135
+ """Return the last unfenced 'Verdict:' value (stripped, lowercased), or None."""
1136
+ value = None
1137
+ for line, _, _ in unfenced_markdown_lines(text):
1138
+ s = line.strip()
1139
+ if s[:8].lower() == "verdict:":
1140
+ value = s[8:].strip().lower() # last one wins
1141
+ return value
1142
+
1143
+
1144
+ def _has_verdict(text: str) -> bool:
1145
+ return _current_verdict(text) is not None
1146
+
1147
+
1148
+ def check_final_review_recorded(run_dir: Path) -> GateResult:
1149
+ """Archive precondition: the final whole-branch review verdict is recorded.
1150
+
1151
+ Presence/audit only — never runs code or tests, never asserts the verdict is
1152
+ true. Same trust level as the PLAN-TASK checkbox gate.
1153
+ """
1154
+ marker = run_dir / "execution" / "final-review.md"
1155
+ text, err = _read_regular_text_no_symlink(marker)
1156
+
1157
+ if err == "missing":
1158
+ return _fail_gate(_CANONICAL_NOT_APPROVED_MSG)
1159
+
1160
+ if err == "symlink":
1161
+ return _fail_gate(
1162
+ "execution/final-review.md is a symlink; refusing to read "
1163
+ "outside the run directory. " + _FINAL_REVIEW_DISCLAIMER
1164
+ )
1165
+
1166
+ if err == "not_regular":
1167
+ return _fail_gate(
1168
+ "execution/final-review.md is not a regular file. "
1169
+ + _FINAL_REVIEW_DISCLAIMER
1170
+ )
1171
+
1172
+ assert text is not None
1173
+
1174
+ if not _has_verdict(text):
1175
+ # Case d: regular file, zero unfenced Verdict: lines
1176
+ return _fail_gate(_CANONICAL_NOT_APPROVED_MSG)
1177
+
1178
+ current = _current_verdict(text) # last unfenced 'Verdict:' value, lowercased
1179
+ assert current is not None # guarded by _has_verdict above
1180
+
1181
+ if current not in _SUPPORTED_VERDICTS:
1182
+ return _fail_gate(
1183
+ "execution/final-review.md current 'Verdict:' value is unsupported. "
1184
+ + _FINAL_REVIEW_DISCLAIMER
1185
+ )
1186
+
1187
+ if current == "changes requested":
1188
+ return _fail_gate(
1189
+ "Final whole-branch review not approved: current 'Verdict:' is "
1190
+ "'Changes Requested'. " + _FINAL_REVIEW_DISCLAIMER
1191
+ )
1192
+
1193
+ # current == "approved"
1194
+ return GateResult(passed=True, issues=[], exit_code=0)
1195
+
1196
+
1113
1197
  # ---------------------------------------------------------------------------
1114
1198
  # Execution Completion Gate
1115
1199
  # ---------------------------------------------------------------------------
@@ -6,6 +6,7 @@ Supports: claude, codex, gemini, opencode
6
6
  from __future__ import annotations
7
7
 
8
8
  import os
9
+ import secrets
9
10
  import json
10
11
  import hashlib
11
12
  import shutil
@@ -17,6 +18,7 @@ from pathlib import Path
17
18
  from typing import Any
18
19
 
19
20
  from tools.workflow_cli.version import R2P_VERSION
21
+ from tools.workflow_cli.atomic import atomic_write_text
20
22
 
21
23
 
22
24
  # ---------------------------------------------------------------------------
@@ -204,15 +206,7 @@ class InstallService:
204
206
  "r2p_version": R2P_VERSION,
205
207
  "schema_version": SCHEMA_VERSION,
206
208
  }
207
- self._validate_install_path(manifest_path, field="manifest")
208
- manifest_path.parent.mkdir(parents=True, exist_ok=True)
209
- tmp = manifest_path.with_name(manifest_path.name + ".tmp")
210
- # The temp sibling shares the (validated) parent, but its own path is
211
- # untrusted: reject a planted symlink so the atomic write cannot be
212
- # redirected outside the manifest dir.
213
- self._validate_install_path(tmp, field="manifest")
214
- tmp.write_text(_dump_manifest(manifest), encoding="utf-8")
215
- tmp.replace(manifest_path)
209
+ self._write_manifest_atomic(manifest_path, _dump_manifest(manifest))
216
210
  manifest_written = True
217
211
 
218
212
  # Remove obsolete managed shared wrappers (e.g. a 0.1.2 r2p-adapt) that
@@ -671,7 +665,14 @@ class InstallService:
671
665
  for mpath in sorted(install_dir.glob("*.yaml")):
672
666
  if self._load_manifest_for_cleanup(mpath) is None:
673
667
  continue
674
- self._strip_path_from_manifest(mpath, path_str)
668
+ try:
669
+ self._strip_path_from_manifest(mpath, path_str)
670
+ except ValueError:
671
+ # Best-effort cleanup: _write_manifest_atomic rejects writes
672
+ # that would follow an untrusted symlink. A symlinked or
673
+ # otherwise unsafe manifest is left in place for operator
674
+ # repair rather than aborting the in-progress install.
675
+ continue
675
676
  # Delete the obsolete managed wrapper only when there was no user
676
677
  # original to restore in its place.
677
678
  if not restored and path_str not in preserve_paths:
@@ -772,7 +773,7 @@ class InstallService:
772
773
  changed = True
773
774
 
774
775
  if changed:
775
- manifest_path.write_text(_dump_manifest(manifest), encoding="utf-8")
776
+ self._write_manifest_atomic(manifest_path, _dump_manifest(manifest))
776
777
 
777
778
  def _load_manifest_for_cleanup(self, manifest_path: Path) -> dict[str, Any] | None:
778
779
  """Load a manifest during best-effort shared-wrapper cleanup.
@@ -829,8 +830,44 @@ class InstallService:
829
830
  file_snapshot.path.chmod(file_snapshot.mode)
830
831
  except OSError:
831
832
  pass
832
- snapshot.manifest_path.parent.mkdir(parents=True, exist_ok=True)
833
- snapshot.manifest_path.write_text(snapshot.manifest_text, encoding="utf-8")
833
+ self._write_manifest_atomic(snapshot.manifest_path, snapshot.manifest_text)
834
+
835
+ def _write_manifest_atomic(self, manifest_path: Path, data: str) -> None:
836
+ """Write manifest data via a unique temp sibling, then atomically replace.
837
+
838
+ Closes the fixed-name temp collision + check-then-write TOCTOU window.
839
+ _validate_install_path is called on both the manifest path and each
840
+ candidate temp path (preserving the existing symlink-rejection rules).
841
+ """
842
+ self._validate_install_path(manifest_path, field="manifest")
843
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
844
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
845
+ flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
846
+ last_err: Exception | None = None
847
+ for _ in range(100):
848
+ tmp = manifest_path.with_name(
849
+ f".{manifest_path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
850
+ )
851
+ self._validate_install_path(tmp, field="manifest")
852
+ try:
853
+ fd = os.open(tmp, flags, 0o666)
854
+ except FileExistsError as exc:
855
+ last_err = exc
856
+ continue
857
+ try:
858
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
859
+ fh.write(data)
860
+ os.replace(tmp, manifest_path)
861
+ return
862
+ except BaseException:
863
+ try:
864
+ tmp.unlink()
865
+ except FileNotFoundError:
866
+ pass
867
+ raise
868
+ raise FileExistsError(
869
+ f"could not create unique manifest temp for {manifest_path}"
870
+ ) from last_err
834
871
 
835
872
  def _validate_install_path(self, path: Path, *, field: str) -> None:
836
873
  """Reject install writes that would follow untrusted symlinks."""
@@ -1084,6 +1121,6 @@ def _safe_write(
1084
1121
  backup = _backup_path(backup_dir, dest)
1085
1122
  shutil.copy2(str(dest), str(backup))
1086
1123
  backups.append({"target": str(dest), "backup": str(backup)})
1087
- dest.write_text(content, encoding="utf-8")
1124
+ atomic_write_text(dest, content)
1088
1125
  installed_paths.append(str(dest))
1089
1126
  written.append(dest)
@@ -1 +1 @@
1
- R2P_VERSION = "0.5.2"
1
+ R2P_VERSION = "0.6.1"