@xenonbyte/req-2-plan 0.4.3 → 0.4.5

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.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Requirement-to-PLAN workflow CLI and agent integration installer.",
5
5
  "bin": {
6
6
  "r2p": "bin/r2p.js"
@@ -14,6 +14,7 @@ import sys
14
14
  from datetime import datetime, timezone
15
15
  from pathlib import Path
16
16
 
17
+ from tools.workflow_cli.atomic import atomic_write_text
17
18
  from tools.workflow_cli.models import RunStatus, WorkId
18
19
  from tools.workflow_cli.output import EXIT_CONFLICT
19
20
 
@@ -53,7 +54,7 @@ def write_active_pointer(base_path: Path, work_id: str, reason: str = "workflow_
53
54
  f"updated_at: {updated_at}\n"
54
55
  f"reason: {reason}\n"
55
56
  )
56
- path.write_text(content, encoding="utf-8")
57
+ atomic_write_text(path, content)
57
58
 
58
59
 
59
60
  def _validate_work_id(raw: str) -> str:
@@ -122,7 +123,9 @@ def generate_work_id(
122
123
  words = [f"run-{h}"]
123
124
 
124
125
  candidate = "-".join(words[:5])
125
- candidate = re.sub(r"-+", "-", candidate).strip("-")[:max_slug_len]
126
+ # Truncate first, then strip dashes: stripping before truncation can leave a
127
+ # trailing "-" at the slice boundary, producing an invalid WorkId.
128
+ candidate = re.sub(r"-+", "-", candidate)[:max_slug_len].strip("-")
126
129
  if len(candidate) < 2:
127
130
  import hashlib
128
131
  h = hashlib.md5(requirement.encode()).hexdigest()[:8]
@@ -138,7 +141,8 @@ def generate_work_id(
138
141
 
139
142
  for n in range(2, 100):
140
143
  suffix = f"-{n}"
141
- alt = f"{prefix}{candidate[:max_slug_len - len(suffix)]}{suffix}"
144
+ alt_candidate = candidate[:max_slug_len - len(suffix)].rstrip("-")
145
+ alt = f"{prefix}{alt_candidate}{suffix}"
142
146
  if not (base_path / ".req-to-plan" / alt).exists():
143
147
  return alt
144
148
 
@@ -234,12 +238,18 @@ def _prev_stage(stage):
234
238
  def _seed_for_stage(stage, tier, upstream_summary: str = "", context_summary: str = "") -> str:
235
239
  """Build the seed text for a stage content file: template + upstream summary + context pack."""
236
240
  from tools.workflow_cli.stage_templates import template_for
241
+ from tools.workflow_cli.markdown import strip_readonly_sections
237
242
  base = tier.base if tier is not None else None
238
243
  text = template_for(stage, base) if base is not None else ""
239
- if upstream_summary.strip():
244
+ # Upstream artifacts persist the read-only blocks they were seeded with
245
+ # (nothing strips them at store time). Strip them here, like every other
246
+ # consumer (gates/trace), so the freshly injected Upstream Summary / Project
247
+ # Context wrappers below are not duplicated or accumulated across stages.
248
+ upstream_summary = strip_readonly_sections(upstream_summary).strip()
249
+ if upstream_summary:
240
250
  text += (
241
251
  "\n## Upstream Summary (read-only)\n"
242
- + upstream_summary.strip()
252
+ + upstream_summary
243
253
  + "\n<!-- /r2p-read-only -->\n"
244
254
  )
245
255
  if context_summary.strip():
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  from datetime import datetime, timezone
10
10
  from pathlib import Path
11
11
 
12
+ from tools.workflow_cli.atomic import atomic_write_text
12
13
  from tools.workflow_cli.models import Stage, STAGE_ARTIFACT_MAP
13
14
 
14
15
 
@@ -98,7 +99,9 @@ def write_artifact(
98
99
  fm, _ = _parse_frontmatter(path.read_text(encoding="utf-8"))
99
100
  created_at = fm.get("r2p_created_at", now)
100
101
  full_text = _frontmatter(stage, version, status, created_at, now) + content
101
- path.write_text(full_text, encoding="utf-8")
102
+ # Atomic write: a crash mid-write must not leave a truncated artifact that
103
+ # fails to parse on the next load. Write a unique sibling temp then replace.
104
+ atomic_write_text(path, full_text)
102
105
  return path
103
106
 
104
107
 
@@ -225,4 +228,4 @@ class ArtifactManager:
225
228
  f"r2p_replaced_by: {replaced_by}\n"
226
229
  f"---\n\n"
227
230
  ) + body
228
- path.write_text(content, encoding="utf-8")
231
+ atomic_write_text(path, content)
@@ -0,0 +1,45 @@
1
+ """Atomic filesystem write helpers."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import secrets
6
+ from pathlib import Path
7
+
8
+
9
+ def atomic_write_text(path: Path, content: str, *, encoding: str = "utf-8") -> None:
10
+ """Write text via a unique sibling temp file, then atomically replace path."""
11
+ tmp_path, fd = _open_unique_sibling_tmp(path)
12
+ try:
13
+ with os.fdopen(fd, "w", encoding=encoding) as tmp:
14
+ fd = -1
15
+ tmp.write(content)
16
+ os.replace(tmp_path, path)
17
+ tmp_path = None
18
+ finally:
19
+ if fd != -1:
20
+ os.close(fd)
21
+ if tmp_path is not None:
22
+ try:
23
+ tmp_path.unlink()
24
+ except FileNotFoundError:
25
+ pass
26
+
27
+
28
+ def _open_unique_sibling_tmp(path: Path) -> tuple[Path, int]:
29
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
30
+ if hasattr(os, "O_CLOEXEC"):
31
+ flags |= os.O_CLOEXEC
32
+ if hasattr(os, "O_NOFOLLOW"):
33
+ flags |= os.O_NOFOLLOW
34
+
35
+ last_error: FileExistsError | None = None
36
+ for _ in range(100):
37
+ candidate = path.with_name(
38
+ f".{path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
39
+ )
40
+ try:
41
+ return candidate, os.open(candidate, flags, 0o666)
42
+ except FileExistsError as exc:
43
+ last_error = exc
44
+
45
+ raise FileExistsError(f"Could not create unique temp file for {path}") from last_error
@@ -231,8 +231,8 @@ def _cmd_run_start(args):
231
231
  link_results = []
232
232
  if repo_path is not None:
233
233
  from tools.workflow_cli.link_expander import expand_links
234
- # Local relative links expand; HTTP is recorded as not-expanded (needs confirmation).
235
- link_results = expand_links(requirement, base_path=repo_path, fetch_urls=False)
234
+ # Local relative links expand; HTTP URLs are recorded as external references only.
235
+ link_results = expand_links(requirement, base_path=repo_path)
236
236
 
237
237
  # Tier estimation
238
238
  tier_estimate, evidence = estimate_tier(requirement, repo_path=repo_path, link_results=link_results)
@@ -473,6 +473,19 @@ def _cmd_run_reopen(args):
473
473
  if cp_stage_idx < target_idx:
474
474
  new_record.approved_checkpoints.append(cp)
475
475
 
476
+ # Repopulate active_artifacts for copied stages so the reopened record
477
+ # matches the on-disk artifacts and approved checkpoints.
478
+ for cp in new_record.approved_checkpoints:
479
+ artifact_name = STAGE_ARTIFACT_MAP.get(cp.stage)
480
+ if artifact_name and (new_run_dir / artifact_name).exists():
481
+ upsert_active_artifact(
482
+ new_record,
483
+ stage=cp.stage,
484
+ artifact=cp.artifact,
485
+ version=cp.version,
486
+ status="approved",
487
+ )
488
+
476
489
  new_mgr = RunStateManager(new_run_dir)
477
490
  new_mgr.save(new_record)
478
491
 
@@ -723,14 +736,6 @@ def _cmd_tier_lock(args):
723
736
  record.tier_estimate = TierEstimate(base=TierBase.LIGHT, modifiers=frozenset())
724
737
 
725
738
  if args.override_floor:
726
- if not args.confirm:
727
- print_and_exit(
728
- format_error(
729
- "Overriding floor requires --confirm flag as well",
730
- exit_code=EXIT_CLI_ERR,
731
- ),
732
- EXIT_CLI_ERR,
733
- )
734
739
  from tools.workflow_cli.models import TierEstimate
735
740
  record.tier_locked = TierEstimate(base=base, modifiers=modifiers)
736
741
  else:
@@ -808,11 +813,12 @@ def _cmd_tier_escalate(args):
808
813
  active_item=record.current_stage.value,
809
814
  )
810
815
 
811
- # Revoke affected bundle authorizations that cover high-tier stages
816
+ # Revoke affected bundle authorizations that cover high-tier stages.
817
+ # Reuse the gates definition so bundle revocation stays in lockstep with
818
+ # forced-review behavior if the high-risk modifier set ever changes.
812
819
  from tools.workflow_cli.gates import _FORCED_REVIEW_MODIFIERS
813
- high_modifiers = {TierModifier.MIGRATION, TierModifier.SAFETY, TierModifier.CROSS_PROJECT}
814
820
  from datetime import datetime, timezone
815
- if modifier in high_modifiers:
821
+ if modifier in _FORCED_REVIEW_MODIFIERS:
816
822
  revoke_ts = datetime.now(timezone.utc).isoformat()
817
823
  from tools.workflow_cli.models import STAGE_REQUIRED_UPSTREAM_CHECKPOINTS
818
824
  affected_stages = {Stage.DESIGN, Stage.SPEC, Stage.PLAN}
@@ -10,6 +10,8 @@ import json
10
10
  import hashlib
11
11
  import shutil
12
12
  import shlex
13
+ import stat
14
+ from dataclasses import dataclass
13
15
  from datetime import datetime, timezone
14
16
  from pathlib import Path
15
17
  from typing import Any
@@ -40,6 +42,21 @@ KNOWN_OBSOLETE_PLATFORM_TARGETS = {
40
42
  }
41
43
 
42
44
 
45
+ @dataclass
46
+ class _FileSnapshot:
47
+ path: Path
48
+ content: bytes
49
+ mode: int
50
+
51
+
52
+ @dataclass
53
+ class _InstallSnapshot:
54
+ manifest_path: Path
55
+ manifest_text: str
56
+ files: list[_FileSnapshot]
57
+ backup_files: list[_FileSnapshot]
58
+
59
+
43
60
  def _now_ts() -> str:
44
61
  """Return UTC timestamp for backup filenames."""
45
62
  return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
@@ -84,17 +101,24 @@ class InstallService:
84
101
  )
85
102
 
86
103
  manifest_path = self._manifest_path(platform)
87
- if manifest_path.exists():
88
- self.uninstall(platform)
104
+ backup_dir = self.manifest_root / "install" / "backups" / platform
105
+ self._validate_install_plan(platform, manifest_path, backup_dir)
106
+ prior_install = (
107
+ self._capture_install_snapshot(platform, manifest_path)
108
+ if manifest_path.exists()
109
+ else None
110
+ )
89
111
 
90
112
  installed_paths: list[str] = []
91
113
  backups: list[dict[str, str]] = []
92
114
  written: list[Path] = []
93
- backup_dir = self.manifest_root / "install" / "backups" / platform
115
+ manifest_written = False
94
116
 
95
117
  try:
118
+ if prior_install is not None:
119
+ self.uninstall(platform)
120
+
96
121
  bin_dir = self.manifest_root / "bin"
97
- bin_dir.mkdir(parents=True, exist_ok=True)
98
122
 
99
123
  # Copy bin scripts
100
124
  for src in sorted(self.repo_root.glob("tools/r2p-*")):
@@ -104,7 +128,7 @@ class InstallService:
104
128
  src.read_text(encoding="utf-8"),
105
129
  self.repo_root,
106
130
  )
107
- _safe_write(
131
+ self._write_managed_file(
108
132
  dest, content, backups, installed_paths, written, backup_dir
109
133
  )
110
134
  shutil.copymode(str(src), str(dest))
@@ -120,7 +144,7 @@ class InstallService:
120
144
  skill_src = template_dir / "SKILL.md"
121
145
  skill_dest = platform_home / "skills" / "r2p" / "SKILL.md"
122
146
  content = _render(skill_src.read_text(), R2P_VERSION, str(bin_dir))
123
- _safe_write(
147
+ self._write_managed_file(
124
148
  skill_dest, content, backups, installed_paths, written, backup_dir
125
149
  )
126
150
 
@@ -129,7 +153,7 @@ class InstallService:
129
153
  for src in sorted(cmd_dir.glob("r2p-*.md")):
130
154
  dest = platform_home / "commands" / src.name
131
155
  content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
132
- _safe_write(
156
+ self._write_managed_file(
133
157
  dest, content, backups, installed_paths, written, backup_dir
134
158
  )
135
159
 
@@ -139,7 +163,7 @@ class InstallService:
139
163
  for src in sorted(skills_dir.glob("r2p-*/SKILL.md")):
140
164
  dest = platform_home / "skills" / src.parent.name / "SKILL.md"
141
165
  content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
142
- _safe_write(
166
+ self._write_managed_file(
143
167
  dest, content, backups, installed_paths, written, backup_dir
144
168
  )
145
169
 
@@ -149,7 +173,7 @@ class InstallService:
149
173
  for src in sorted(cmd_dir.glob("r2p-*.toml")):
150
174
  dest = platform_home / "commands" / src.name
151
175
  content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
152
- _safe_write(
176
+ self._write_managed_file(
153
177
  dest, content, backups, installed_paths, written, backup_dir
154
178
  )
155
179
 
@@ -162,8 +186,16 @@ class InstallService:
162
186
  "r2p_version": R2P_VERSION,
163
187
  "schema_version": SCHEMA_VERSION,
164
188
  }
189
+ self._validate_install_path(manifest_path, field="manifest")
165
190
  manifest_path.parent.mkdir(parents=True, exist_ok=True)
166
- manifest_path.write_text(_dump_manifest(manifest), encoding="utf-8")
191
+ tmp = manifest_path.with_name(manifest_path.name + ".tmp")
192
+ # The temp sibling shares the (validated) parent, but its own path is
193
+ # untrusted: reject a planted symlink so the atomic write cannot be
194
+ # redirected outside the manifest dir.
195
+ self._validate_install_path(tmp, field="manifest")
196
+ tmp.write_text(_dump_manifest(manifest), encoding="utf-8")
197
+ tmp.replace(manifest_path)
198
+ manifest_written = True
167
199
 
168
200
  # Remove obsolete managed shared wrappers (e.g. a 0.1.2 r2p-adapt) that
169
201
  # are no longer part of the current template set, across all manifests.
@@ -172,6 +204,12 @@ class InstallService:
172
204
 
173
205
  except Exception:
174
206
  # Rollback: remove written files, restore backups
207
+ if manifest_written:
208
+ # A pre-existing manifest is impossible here: a fresh install
209
+ # never had one, and a reinstall's uninstall already removed
210
+ # it — prior-manifest restoration is owned by
211
+ # _restore_install_snapshot below.
212
+ manifest_path.unlink(missing_ok=True)
175
213
  for path in reversed(written):
176
214
  try:
177
215
  path.unlink(missing_ok=True)
@@ -183,6 +221,9 @@ class InstallService:
183
221
  if backup_path.exists():
184
222
  target_path.parent.mkdir(parents=True, exist_ok=True)
185
223
  shutil.copy2(str(backup_path), str(target_path))
224
+ backup_path.unlink(missing_ok=True)
225
+ if prior_install is not None:
226
+ self._restore_install_snapshot(prior_install)
186
227
  raise
187
228
 
188
229
  def uninstall(self, platform: str) -> dict:
@@ -488,31 +529,39 @@ class InstallService:
488
529
  if platform not in SUPPORTED_PLATFORMS:
489
530
  raise ValueError(f"unsafe_manifest: unsupported_platform {platform!r}")
490
531
 
532
+ targets = set(self._install_targets(platform))
533
+ bin_dir = self.manifest_root / "bin"
534
+ targets.update(bin_dir / name for name in KNOWN_OBSOLETE_SHARED_WRAPPERS)
535
+
536
+ platform_home = self.platform_homes[platform]
537
+ for parts in KNOWN_OBSOLETE_PLATFORM_TARGETS.get(platform, ()):
538
+ targets.add(platform_home.joinpath(*parts))
539
+
540
+ return targets
541
+
542
+ def _install_targets(self, platform: str) -> list[Path]:
491
543
  bin_dir = self.manifest_root / "bin"
492
- targets = {
544
+ targets = [
493
545
  bin_dir / src.name
494
546
  for src in sorted(self.repo_root.glob("tools/r2p-*"))
495
547
  if src.is_file()
496
- }
497
- targets.update(bin_dir / name for name in KNOWN_OBSOLETE_SHARED_WRAPPERS)
548
+ ]
498
549
 
499
550
  template_dir = (
500
551
  self.repo_root / "tools" / "workflow_cli" / "agent_templates" / platform
501
552
  )
502
553
  platform_home = self.platform_homes[platform]
503
- for parts in KNOWN_OBSOLETE_PLATFORM_TARGETS.get(platform, ()):
504
- targets.add(platform_home.joinpath(*parts))
505
554
 
506
555
  if platform == "claude":
507
- targets.add(platform_home / "skills" / "r2p" / "SKILL.md")
556
+ targets.append(platform_home / "skills" / "r2p" / "SKILL.md")
508
557
  for src in sorted((template_dir / "commands").glob("r2p-*.md")):
509
- targets.add(platform_home / "commands" / src.name)
558
+ targets.append(platform_home / "commands" / src.name)
510
559
  elif platform == "codex":
511
560
  for src in sorted((template_dir / "skills").glob("r2p-*/SKILL.md")):
512
- targets.add(platform_home / "skills" / src.parent.name / "SKILL.md")
561
+ targets.append(platform_home / "skills" / src.parent.name / "SKILL.md")
513
562
  elif platform == "gemini":
514
563
  for src in sorted((template_dir / "commands").glob("r2p-*.toml")):
515
- targets.add(platform_home / "commands" / src.name)
564
+ targets.append(platform_home / "commands" / src.name)
516
565
 
517
566
  return targets
518
567
 
@@ -703,6 +752,99 @@ class InstallService:
703
752
  except (OSError, UnicodeDecodeError, ValueError):
704
753
  return None
705
754
 
755
+ def _capture_install_snapshot(
756
+ self,
757
+ platform: str,
758
+ manifest_path: Path,
759
+ ) -> _InstallSnapshot:
760
+ manifest = _load_manifest(manifest_path)
761
+ self._validate_manifest_for_uninstall(platform, manifest)
762
+ files: list[_FileSnapshot] = []
763
+ backup_files: list[_FileSnapshot] = []
764
+
765
+ for path_str in manifest.get("installed_paths", []):
766
+ snapshot = self._snapshot_file(Path(path_str))
767
+ if snapshot is not None:
768
+ files.append(snapshot)
769
+
770
+ for backup in manifest.get("backups", []):
771
+ snapshot = self._snapshot_file(Path(backup["backup"]))
772
+ if snapshot is not None:
773
+ backup_files.append(snapshot)
774
+
775
+ return _InstallSnapshot(
776
+ manifest_path=manifest_path,
777
+ manifest_text=manifest_path.read_text(encoding="utf-8"),
778
+ files=files,
779
+ backup_files=backup_files,
780
+ )
781
+
782
+ def _snapshot_file(self, path: Path) -> _FileSnapshot | None:
783
+ if not path.is_file():
784
+ return None
785
+ return _FileSnapshot(
786
+ path=path,
787
+ content=path.read_bytes(),
788
+ mode=stat.S_IMODE(path.stat().st_mode),
789
+ )
790
+
791
+ def _restore_install_snapshot(self, snapshot: _InstallSnapshot) -> None:
792
+ for file_snapshot in snapshot.files + snapshot.backup_files:
793
+ file_snapshot.path.parent.mkdir(parents=True, exist_ok=True)
794
+ file_snapshot.path.write_bytes(file_snapshot.content)
795
+ try:
796
+ file_snapshot.path.chmod(file_snapshot.mode)
797
+ except OSError:
798
+ pass
799
+ snapshot.manifest_path.parent.mkdir(parents=True, exist_ok=True)
800
+ snapshot.manifest_path.write_text(snapshot.manifest_text, encoding="utf-8")
801
+
802
+ def _validate_install_path(self, path: Path, *, field: str) -> None:
803
+ """Reject install writes that would follow untrusted symlinks."""
804
+ if path.is_symlink():
805
+ raise ValueError(f"unsafe_install: {field}_is_symlink")
806
+ normalized = self._normalize_without_resolving_symlinks(path)
807
+ try:
808
+ self._reject_symlinked_ancestors(normalized, field=field)
809
+ except ValueError as exc:
810
+ raise ValueError(
811
+ str(exc).replace("unsafe_manifest:", "unsafe_install:", 1)
812
+ ) from exc
813
+
814
+ def _validate_install_backup_dir(self, backup_dir: Path) -> None:
815
+ probe = backup_dir / "__r2p_probe__"
816
+ normalized = self._normalize_without_resolving_symlinks(probe)
817
+ try:
818
+ self._reject_symlinked_ancestors(normalized, field="backup_dir")
819
+ except ValueError as exc:
820
+ raise ValueError(
821
+ str(exc).replace("unsafe_manifest:", "unsafe_install:", 1)
822
+ ) from exc
823
+
824
+ def _validate_install_plan(
825
+ self,
826
+ platform: str,
827
+ manifest_path: Path,
828
+ backup_dir: Path,
829
+ ) -> None:
830
+ for target in self._install_targets(platform):
831
+ self._validate_install_path(target, field="install_destination")
832
+ self._validate_install_path(manifest_path, field="manifest")
833
+ self._validate_install_backup_dir(backup_dir)
834
+
835
+ def _write_managed_file(
836
+ self,
837
+ dest: Path,
838
+ content: str,
839
+ backups: list[dict[str, str]],
840
+ installed_paths: list[str],
841
+ written: list[Path],
842
+ backup_dir: Path,
843
+ ) -> None:
844
+ self._validate_install_path(dest, field="install_destination")
845
+ self._validate_install_backup_dir(backup_dir)
846
+ _safe_write(dest, content, backups, installed_paths, written, backup_dir)
847
+
706
848
 
707
849
  # ---------------------------------------------------------------------------
708
850
  # Internal helpers
@@ -2,14 +2,11 @@ from __future__ import annotations
2
2
  from dataclasses import dataclass
3
3
  from enum import Enum
4
4
  from pathlib import Path
5
- from urllib import request, error as urllib_error
6
5
  import re
7
6
 
8
7
 
9
8
  class LinkStatus(str, Enum):
10
- REACHABLE = "reachable"
11
- UNREACHABLE = "unreachable"
12
- REQUIRES_AUTH = "requires_auth"
9
+ EXTERNAL = "external" # http(s) link recorded as an external reference; not fetched
13
10
  LOCAL_FOUND = "local_found"
14
11
  LOCAL_MISSING = "local_missing"
15
12
 
@@ -45,20 +42,6 @@ def extract_links(text: str) -> list[str]:
45
42
  return result
46
43
 
47
44
 
48
- def _fetch_url(url: str) -> LinkExpansionResult:
49
- try:
50
- req = request.Request(url, headers={"User-Agent": "r2p-link-expander/1.0"})
51
- with request.urlopen(req, timeout=5) as resp:
52
- content = resp.read(500).decode("utf-8", errors="replace")
53
- return LinkExpansionResult(url=url, status=LinkStatus.REACHABLE, content_preview=content)
54
- except urllib_error.HTTPError as e:
55
- if e.code in (401, 403):
56
- return LinkExpansionResult(url=url, status=LinkStatus.REQUIRES_AUTH, error=str(e))
57
- return LinkExpansionResult(url=url, status=LinkStatus.UNREACHABLE, error=str(e))
58
- except Exception as e:
59
- return LinkExpansionResult(url=url, status=LinkStatus.UNREACHABLE, error=str(e))
60
-
61
-
62
45
  def _local_preview_block_reason(path_str: str, candidate: Path, base: Path | None) -> str | None:
63
46
  try:
64
47
  relative = candidate.relative_to(base) if base is not None else Path(path_str)
@@ -109,8 +92,14 @@ def _expand_local(path_str: str, base_path: Path | None) -> LinkExpansionResult:
109
92
  def expand_links(
110
93
  text: str,
111
94
  base_path: Path | None = None,
112
- fetch_urls: bool = True,
113
95
  ) -> list[LinkExpansionResult]:
96
+ """Expand links found in requirement text.
97
+
98
+ Local relative links are read for context. http(s) URLs are recorded as
99
+ external references only: r2p never makes outbound requests for URLs found in
100
+ (untrusted) requirement text, so a link cannot drive the tool to reach
101
+ cloud-metadata endpoints, localhost, or internal services.
102
+ """
114
103
  results = []
115
104
  links = extract_links(text)
116
105
  seen: set[str] = set()
@@ -119,12 +108,7 @@ def expand_links(
119
108
  continue
120
109
  seen.add(link)
121
110
  if link.startswith("http://") or link.startswith("https://"):
122
- if fetch_urls:
123
- results.append(_fetch_url(link))
124
- else:
125
- results.append(LinkExpansionResult(
126
- url=link, status=LinkStatus.UNREACHABLE, error="URL fetching disabled"
127
- ))
111
+ results.append(LinkExpansionResult(url=link, status=LinkStatus.EXTERNAL))
128
112
  else:
129
113
  results.append(_expand_local(link, base_path))
130
114
  return results
@@ -8,6 +8,7 @@ from datetime import datetime, timezone
8
8
  from pathlib import Path
9
9
  from typing import Optional
10
10
 
11
+ from tools.workflow_cli.atomic import atomic_write_text
11
12
  from tools.workflow_cli.models import (
12
13
  ActiveArtifact,
13
14
  BundleAuthorization,
@@ -227,11 +228,6 @@ def _escape_cell(val: str) -> str:
227
228
  return val.replace("\\", "\\\\").replace("|", "\\|")
228
229
 
229
230
 
230
- def _unescape_cell(val: str) -> str:
231
- """Unescape backslashes and pipes in a parsed markdown table cell."""
232
- return val.replace("\\|", "|").replace("\\\\", "\\")
233
-
234
-
235
231
  def _split_cells(row_text: str) -> list[str]:
236
232
  """Split pipe-delimited row, honouring \\| and \\\\ escape sequences."""
237
233
  cells: list[str] = []
@@ -608,7 +604,10 @@ class RunStateManager:
608
604
 
609
605
  def save(self, record: RunRecord) -> None:
610
606
  self.run_dir.mkdir(parents=True, exist_ok=True)
611
- self.run_path.write_text(run_record_to_markdown(record), encoding="utf-8")
607
+ text = run_record_to_markdown(record)
608
+ # Atomic write: a crash mid-write must not leave a truncated run.md that
609
+ # bricks the run. Write a unique sibling temp then atomically replace.
610
+ atomic_write_text(self.run_path, text)
612
611
 
613
612
  def load(self) -> RunRecord:
614
613
  if not self.run_path.exists():
@@ -106,7 +106,7 @@ def compute_floor(
106
106
  or repo.is_monorepo
107
107
  or repo.module_count > module_threshold
108
108
  or _has_multi_repo_refs(requirement_text)
109
- or any(r.status in (LinkStatus.UNREACHABLE, LinkStatus.REQUIRES_AUTH) for r in link_results)
109
+ or any(r.status == LinkStatus.EXTERNAL for r in link_results)
110
110
  ):
111
111
  base = TierBase.STANDARD
112
112
 
@@ -1 +1 @@
1
- R2P_VERSION = "0.4.3"
1
+ R2P_VERSION = "0.4.5"