@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenonbyte/req-2-plan",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
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,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
- 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()
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
- 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
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
- "approved_checkpoints": [cp.stage.value for cp in record.approved_checkpoints],
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("--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
+ )
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.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
@@ -204,15 +205,7 @@ class InstallService:
204
205
  "r2p_version": R2P_VERSION,
205
206
  "schema_version": SCHEMA_VERSION,
206
207
  }
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)
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
- self._strip_path_from_manifest(mpath, path_str)
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
- manifest_path.write_text(_dump_manifest(manifest), encoding="utf-8")
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.parent.mkdir(parents=True, exist_ok=True)
833
- snapshot.manifest_path.write_text(snapshot.manifest_text, encoding="utf-8")
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.5.1"
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: