@xenonbyte/req-2-plan 0.4.4 → 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.4",
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}
@@ -188,7 +188,13 @@ class InstallService:
188
188
  }
189
189
  self._validate_install_path(manifest_path, field="manifest")
190
190
  manifest_path.parent.mkdir(parents=True, exist_ok=True)
191
- 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)
192
198
  manifest_written = True
193
199
 
194
200
  # Remove obsolete managed shared wrappers (e.g. a 0.1.2 r2p-adapt) that
@@ -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.4"
1
+ R2P_VERSION = "0.4.5"