@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 +1 -1
- package/tools/workflow_cli/agent_shortcuts.py +15 -5
- package/tools/workflow_cli/artifact.py +5 -2
- package/tools/workflow_cli/atomic.py +45 -0
- package/tools/workflow_cli/cli.py +19 -13
- package/tools/workflow_cli/install.py +7 -1
- package/tools/workflow_cli/link_expander.py +9 -25
- package/tools/workflow_cli/state.py +5 -6
- package/tools/workflow_cli/tier.py +1 -1
- package/tools/workflow_cli/version.py +1 -1
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
235
|
-
link_results = expand_links(requirement, base_path=repo_path
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1
|
+
R2P_VERSION = "0.4.5"
|