@xenonbyte/req-2-plan 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +112 -4
- package/tools/workflow_cli/context_pack.py +6 -2
- package/tools/workflow_cli/gates.py +84 -0
- package/tools/workflow_cli/install.py +49 -13
- package/tools/workflow_cli/output.py +26 -0
- package/tools/workflow_cli/version.py +1 -1
- package/tools/workflow_cli/workspace.py +1 -1
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,11 +47,16 @@ 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 (
|
|
53
|
+
COMPACT_DETAIL_LIMIT,
|
|
54
|
+
COMPACT_FILE_LIST_LIMIT,
|
|
55
|
+
compact_human_list,
|
|
52
56
|
format_success,
|
|
53
57
|
format_error,
|
|
54
58
|
format_gate_result,
|
|
59
|
+
is_json_mode,
|
|
55
60
|
print_and_exit,
|
|
56
61
|
EXIT_OK,
|
|
57
62
|
EXIT_CLI_ERR,
|
|
@@ -95,6 +100,53 @@ def _load_run(work_id: str, base_path: Path | None = None):
|
|
|
95
100
|
)
|
|
96
101
|
|
|
97
102
|
|
|
103
|
+
def _write_recovery_list(run_dir: Path, work_id: str, filename: str, items: list[str]) -> str | None:
|
|
104
|
+
logs_dir = run_dir / "logs"
|
|
105
|
+
if logs_dir.is_symlink():
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
recovery_path = logs_dir / filename
|
|
110
|
+
atomic_write_text(recovery_path, "\n".join(items) + "\n")
|
|
111
|
+
except OSError:
|
|
112
|
+
return None
|
|
113
|
+
return f".req-to-plan/{work_id}/logs/{filename}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _human_list_payload(
|
|
117
|
+
*,
|
|
118
|
+
run_dir: Path,
|
|
119
|
+
work_id: str,
|
|
120
|
+
label: str,
|
|
121
|
+
items: list,
|
|
122
|
+
limit: int,
|
|
123
|
+
recovery_filename: str,
|
|
124
|
+
recovery_items: list[str] | None = None,
|
|
125
|
+
) -> dict:
|
|
126
|
+
if is_json_mode() or len(items) <= limit:
|
|
127
|
+
return {label: items}
|
|
128
|
+
|
|
129
|
+
recovery_path = _write_recovery_list(
|
|
130
|
+
run_dir,
|
|
131
|
+
work_id,
|
|
132
|
+
recovery_filename,
|
|
133
|
+
recovery_items if recovery_items is not None else [str(item) for item in items],
|
|
134
|
+
)
|
|
135
|
+
if recovery_path is None:
|
|
136
|
+
return {label: items}
|
|
137
|
+
|
|
138
|
+
payload = compact_human_list(
|
|
139
|
+
label=label,
|
|
140
|
+
items=items,
|
|
141
|
+
limit=limit,
|
|
142
|
+
recovery_path=recovery_path,
|
|
143
|
+
)
|
|
144
|
+
payload[f"{label}_summary"] = (
|
|
145
|
+
f"{payload[f'{label}_shown']} shown, {payload[f'{label}_total']} total"
|
|
146
|
+
)
|
|
147
|
+
return payload
|
|
148
|
+
|
|
149
|
+
|
|
98
150
|
def _validate_work_id(raw: str) -> WorkId:
|
|
99
151
|
"""Parse WorkId or exit with CLI error."""
|
|
100
152
|
try:
|
|
@@ -233,7 +285,16 @@ def _cmd_run_start(args):
|
|
|
233
285
|
format_error("Requirement must not be blank", exit_code=EXIT_CLI_ERR),
|
|
234
286
|
EXIT_CLI_ERR,
|
|
235
287
|
)
|
|
236
|
-
|
|
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()
|
|
237
298
|
run_dir = _reject_symlinked_run_paths(work_id, args.base_path)
|
|
238
299
|
mgr = RunStateManager(run_dir)
|
|
239
300
|
|
|
@@ -334,6 +395,15 @@ def _cmd_run_start(args):
|
|
|
334
395
|
def _cmd_run_resume(args):
|
|
335
396
|
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
336
397
|
rc = record.resume_context
|
|
398
|
+
reread_targets = list(rc.required_reread_targets)
|
|
399
|
+
reread_payload = _human_list_payload(
|
|
400
|
+
run_dir=run_dir,
|
|
401
|
+
work_id=str(record.work_id),
|
|
402
|
+
label="required_reread_targets",
|
|
403
|
+
items=reread_targets,
|
|
404
|
+
limit=COMPACT_FILE_LIST_LIMIT,
|
|
405
|
+
recovery_filename="run-resume-reread-targets.txt",
|
|
406
|
+
)
|
|
337
407
|
print_and_exit(
|
|
338
408
|
format_success(
|
|
339
409
|
{
|
|
@@ -344,6 +414,7 @@ def _cmd_run_resume(args):
|
|
|
344
414
|
"next_operation": rc.next_allowed_operation,
|
|
345
415
|
"active_item": rc.active_item,
|
|
346
416
|
"resume_reason": rc.resume_reason,
|
|
417
|
+
**reread_payload,
|
|
347
418
|
},
|
|
348
419
|
message="Resume context",
|
|
349
420
|
),
|
|
@@ -552,7 +623,13 @@ def _cmd_run_reopen(args):
|
|
|
552
623
|
last_operation="reopen_from_execution",
|
|
553
624
|
next_operation=f"continue_reopened_run:{new_work_id}",
|
|
554
625
|
)
|
|
555
|
-
|
|
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
|
|
556
633
|
|
|
557
634
|
print_and_exit(
|
|
558
635
|
format_success(
|
|
@@ -595,6 +672,17 @@ def _cmd_run_archive(args):
|
|
|
595
672
|
),
|
|
596
673
|
gate.exit_code,
|
|
597
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
|
+
)
|
|
598
686
|
# 1. Refuse to clobber an existing archived copy before mutating state.
|
|
599
687
|
archive_dir = base / ".req-to-plan" / "archive" / str(record.work_id)
|
|
600
688
|
if archive_dir.exists():
|
|
@@ -1223,6 +1311,22 @@ def _cmd_status_run(args):
|
|
|
1223
1311
|
for s in record.stale_artifacts
|
|
1224
1312
|
]
|
|
1225
1313
|
outstanding_stale = [aa.stage.value for aa in record.active_artifacts if aa.status == "stale"]
|
|
1314
|
+
approved_checkpoints = [cp.stage.value for cp in record.approved_checkpoints]
|
|
1315
|
+
approved_payload = _human_list_payload(
|
|
1316
|
+
run_dir=run_dir,
|
|
1317
|
+
work_id=str(record.work_id),
|
|
1318
|
+
label="approved_checkpoints",
|
|
1319
|
+
items=approved_checkpoints,
|
|
1320
|
+
limit=COMPACT_DETAIL_LIMIT,
|
|
1321
|
+
recovery_filename="status-run-approved-checkpoints.txt",
|
|
1322
|
+
recovery_items=[
|
|
1323
|
+
(
|
|
1324
|
+
f"{cp.stage.value}\t{cp.artifact}\tv{cp.version}\t"
|
|
1325
|
+
f"{cp.approved_at}\t{cp.downstream_authorization}\t{cp.bundle_id or ''}"
|
|
1326
|
+
)
|
|
1327
|
+
for cp in record.approved_checkpoints
|
|
1328
|
+
],
|
|
1329
|
+
)
|
|
1226
1330
|
|
|
1227
1331
|
print_and_exit(
|
|
1228
1332
|
format_success(
|
|
@@ -1237,7 +1341,7 @@ def _cmd_status_run(args):
|
|
|
1237
1341
|
"open_routes_detail": open_routes_detail,
|
|
1238
1342
|
"stale_artifacts": stale_artifacts,
|
|
1239
1343
|
"outstanding_stale": outstanding_stale,
|
|
1240
|
-
|
|
1344
|
+
**approved_payload,
|
|
1241
1345
|
},
|
|
1242
1346
|
message="Run status",
|
|
1243
1347
|
),
|
|
@@ -1537,7 +1641,11 @@ def _register_run_commands(subparsers):
|
|
|
1537
1641
|
default=None,
|
|
1538
1642
|
help="Path to a file whose contents are the raw requirement",
|
|
1539
1643
|
)
|
|
1540
|
-
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
|
+
)
|
|
1541
1649
|
p.add_argument("--overwrite", action="store_true", help="Overwrite an existing run")
|
|
1542
1650
|
p.set_defaults(func=_cmd_run_start)
|
|
1543
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
|
|
@@ -204,15 +205,7 @@ class InstallService:
|
|
|
204
205
|
"r2p_version": R2P_VERSION,
|
|
205
206
|
"schema_version": SCHEMA_VERSION,
|
|
206
207
|
}
|
|
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)
|
|
208
|
+
self._write_manifest_atomic(manifest_path, _dump_manifest(manifest))
|
|
216
209
|
manifest_written = True
|
|
217
210
|
|
|
218
211
|
# Remove obsolete managed shared wrappers (e.g. a 0.1.2 r2p-adapt) that
|
|
@@ -671,7 +664,14 @@ class InstallService:
|
|
|
671
664
|
for mpath in sorted(install_dir.glob("*.yaml")):
|
|
672
665
|
if self._load_manifest_for_cleanup(mpath) is None:
|
|
673
666
|
continue
|
|
674
|
-
|
|
667
|
+
try:
|
|
668
|
+
self._strip_path_from_manifest(mpath, path_str)
|
|
669
|
+
except ValueError:
|
|
670
|
+
# Best-effort cleanup: _write_manifest_atomic rejects writes
|
|
671
|
+
# that would follow an untrusted symlink. A symlinked or
|
|
672
|
+
# otherwise unsafe manifest is left in place for operator
|
|
673
|
+
# repair rather than aborting the in-progress install.
|
|
674
|
+
continue
|
|
675
675
|
# Delete the obsolete managed wrapper only when there was no user
|
|
676
676
|
# original to restore in its place.
|
|
677
677
|
if not restored and path_str not in preserve_paths:
|
|
@@ -772,7 +772,7 @@ class InstallService:
|
|
|
772
772
|
changed = True
|
|
773
773
|
|
|
774
774
|
if changed:
|
|
775
|
-
|
|
775
|
+
self._write_manifest_atomic(manifest_path, _dump_manifest(manifest))
|
|
776
776
|
|
|
777
777
|
def _load_manifest_for_cleanup(self, manifest_path: Path) -> dict[str, Any] | None:
|
|
778
778
|
"""Load a manifest during best-effort shared-wrapper cleanup.
|
|
@@ -829,8 +829,44 @@ class InstallService:
|
|
|
829
829
|
file_snapshot.path.chmod(file_snapshot.mode)
|
|
830
830
|
except OSError:
|
|
831
831
|
pass
|
|
832
|
-
snapshot.manifest_path
|
|
833
|
-
|
|
832
|
+
self._write_manifest_atomic(snapshot.manifest_path, snapshot.manifest_text)
|
|
833
|
+
|
|
834
|
+
def _write_manifest_atomic(self, manifest_path: Path, data: str) -> None:
|
|
835
|
+
"""Write manifest data via a unique temp sibling, then atomically replace.
|
|
836
|
+
|
|
837
|
+
Closes the fixed-name temp collision + check-then-write TOCTOU window.
|
|
838
|
+
_validate_install_path is called on both the manifest path and each
|
|
839
|
+
candidate temp path (preserving the existing symlink-rejection rules).
|
|
840
|
+
"""
|
|
841
|
+
self._validate_install_path(manifest_path, field="manifest")
|
|
842
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
843
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
844
|
+
flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
845
|
+
last_err: Exception | None = None
|
|
846
|
+
for _ in range(100):
|
|
847
|
+
tmp = manifest_path.with_name(
|
|
848
|
+
f".{manifest_path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
|
|
849
|
+
)
|
|
850
|
+
self._validate_install_path(tmp, field="manifest")
|
|
851
|
+
try:
|
|
852
|
+
fd = os.open(tmp, flags, 0o666)
|
|
853
|
+
except FileExistsError as exc:
|
|
854
|
+
last_err = exc
|
|
855
|
+
continue
|
|
856
|
+
try:
|
|
857
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
858
|
+
fh.write(data)
|
|
859
|
+
os.replace(tmp, manifest_path)
|
|
860
|
+
return
|
|
861
|
+
except BaseException:
|
|
862
|
+
try:
|
|
863
|
+
tmp.unlink()
|
|
864
|
+
except FileNotFoundError:
|
|
865
|
+
pass
|
|
866
|
+
raise
|
|
867
|
+
raise FileExistsError(
|
|
868
|
+
f"could not create unique manifest temp for {manifest_path}"
|
|
869
|
+
) from last_err
|
|
834
870
|
|
|
835
871
|
def _validate_install_path(self, path: Path, *, field: str) -> None:
|
|
836
872
|
"""Reject install writes that would follow untrusted symlinks."""
|
|
@@ -11,12 +11,38 @@ EXIT_REVIEW_REQ = 5 # forced subagent review required
|
|
|
11
11
|
EXIT_CONFLICT = 6 # state conflict (run already closed, etc.)
|
|
12
12
|
EXIT_NOT_FOUND = 7 # resource not found (run.md, artifact, etc.)
|
|
13
13
|
|
|
14
|
+
# Opt-in compact display limits. Default formatters remain uncapped.
|
|
15
|
+
COMPACT_DETAIL_LIMIT = 10
|
|
16
|
+
COMPACT_FILE_LIST_LIMIT = 15
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
def is_json_mode() -> bool:
|
|
16
20
|
"""Check if JSON output mode is enabled via R2P_JSON environment variable."""
|
|
17
21
|
return os.environ.get("R2P_JSON", "0") == "1"
|
|
18
22
|
|
|
19
23
|
|
|
24
|
+
def compact_human_list(
|
|
25
|
+
*,
|
|
26
|
+
label: str,
|
|
27
|
+
items: list,
|
|
28
|
+
limit: int,
|
|
29
|
+
recovery_path: str | None = None,
|
|
30
|
+
) -> dict:
|
|
31
|
+
"""Build an opt-in compact list payload without touching the filesystem."""
|
|
32
|
+
if limit < 0:
|
|
33
|
+
raise ValueError("limit must be non-negative")
|
|
34
|
+
|
|
35
|
+
visible_items = list(items[:limit])
|
|
36
|
+
result = {
|
|
37
|
+
label: visible_items,
|
|
38
|
+
f"{label}_shown": len(visible_items),
|
|
39
|
+
f"{label}_total": len(items),
|
|
40
|
+
}
|
|
41
|
+
if recovery_path:
|
|
42
|
+
result[f"{label}_full_list"] = recovery_path
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
20
46
|
def format_success(data: dict, message: str = "") -> str:
|
|
21
47
|
"""Format a success response."""
|
|
22
48
|
if is_json_mode():
|
|
@@ -1 +1 @@
|
|
|
1
|
-
R2P_VERSION = "0.
|
|
1
|
+
R2P_VERSION = "0.6.0"
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
|
|
15
15
|
from tools.workflow_cli.atomic import atomic_write_text
|
|
16
16
|
|
|
17
|
-
_WORKSPACE_GITIGNORE_LINES = ("/archive", "/.workflow-active")
|
|
17
|
+
_WORKSPACE_GITIGNORE_LINES = ("/archive", "/.workflow-active", "/*/logs/")
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def ensure_workspace_gitignore(base_path: Path) -> None:
|