@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 +6 -7
- package/README.zh-CN.md +5 -5
- package/package.json +1 -1
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-execute.md +32 -7
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +1 -1
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-execute/SKILL.md +32 -7
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +1 -1
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +1 -1
- package/tools/workflow_cli/cli.py +36 -5
- package/tools/workflow_cli/context_pack.py +6 -2
- package/tools/workflow_cli/gates.py +84 -0
- package/tools/workflow_cli/install.py +51 -14
- package/tools/workflow_cli/version.py +1 -1
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
|
|
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
|
|
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 --
|
|
119
|
+
/r2p-start --file change-req.md
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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
|
|
104
|
+
/r2p-start "Add rate limiting"
|
|
105
105
|
/r2p-continue
|
|
106
106
|
```
|
|
107
107
|
|
|
108
108
|
也可以从需求文件启动,而不是传内联文本:
|
|
109
109
|
|
|
110
110
|
```text
|
|
111
|
-
/r2p-start --
|
|
111
|
+
/r2p-start --file change-req.md
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
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
|
@@ -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.
|
|
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
|
-
-
|
|
93
|
-
-
|
|
94
|
-
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
-
|
|
94
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
171
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
833
|
-
|
|
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
|
|
1124
|
+
atomic_write_text(dest, content)
|
|
1088
1125
|
installed_paths.append(str(dest))
|
|
1089
1126
|
written.append(dest)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
R2P_VERSION = "0.
|
|
1
|
+
R2P_VERSION = "0.6.1"
|