@xenonbyte/req-2-plan 0.4.4 → 0.5.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/README.md +170 -125
- package/README.zh-CN.md +154 -108
- package/package.json +1 -1
- package/tools/r2p-archive +10 -0
- package/tools/r2p-execute +10 -0
- package/tools/workflow_cli/agent_shortcuts.py +310 -26
- package/tools/workflow_cli/agent_templates/claude/SKILL.md +2 -1
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-archive.md +10 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +1 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-execute.md +122 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +2 -2
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-archive/SKILL.md +14 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +2 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-execute/SKILL.md +123 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +2 -2
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-archive.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +1 -1
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-execute.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +1 -1
- package/tools/workflow_cli/artifact.py +5 -2
- package/tools/workflow_cli/atomic.py +50 -0
- package/tools/workflow_cli/cli.py +229 -31
- package/tools/workflow_cli/gates.py +149 -2
- package/tools/workflow_cli/install.py +56 -3
- package/tools/workflow_cli/link_expander.py +9 -25
- package/tools/workflow_cli/markdown.py +18 -0
- package/tools/workflow_cli/models.py +19 -1
- package/tools/workflow_cli/stage_templates.py +2 -1
- 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/tools/workflow_cli/workspace.py +112 -0
|
@@ -6,7 +6,10 @@ The Agent handles semantic quality; this module handles structural validation.
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import errno
|
|
10
|
+
import os
|
|
9
11
|
import re
|
|
12
|
+
import stat
|
|
10
13
|
from collections import Counter
|
|
11
14
|
from dataclasses import dataclass, field
|
|
12
15
|
from pathlib import Path
|
|
@@ -23,6 +26,8 @@ from tools.workflow_cli.models import (
|
|
|
23
26
|
from tools.workflow_cli.markdown import (
|
|
24
27
|
heading_bounded_bodies,
|
|
25
28
|
heading_level,
|
|
29
|
+
plan_task_anchors,
|
|
30
|
+
PLAN_TASK_ANCHOR_RE as _PLAN_TASK_RE,
|
|
26
31
|
strip_readonly_sections,
|
|
27
32
|
unfenced_markdown_lines,
|
|
28
33
|
unfenced_markdown_text,
|
|
@@ -91,7 +96,7 @@ def check_entry_gate(
|
|
|
91
96
|
# Upstream ID reference pattern
|
|
92
97
|
# ---------------------------------------------------------------------------
|
|
93
98
|
|
|
94
|
-
_FILL_IN_PLACEHOLDER_RE = re.compile(r"<!--\s*fill in
|
|
99
|
+
_FILL_IN_PLACEHOLDER_RE = re.compile(r"<!--\s*fill in(?:\s*:.*?)?\s*-->", re.IGNORECASE)
|
|
95
100
|
|
|
96
101
|
_PLACEHOLDER_PATTERNS = [
|
|
97
102
|
_FILL_IN_PLACEHOLDER_RE, # untouched template body
|
|
@@ -103,6 +108,9 @@ _PLACEHOLDER_PATTERNS = [
|
|
|
103
108
|
re.compile(
|
|
104
109
|
r"(?im)^\s*(?:[-*]\s*)?(?:[A-Za-z][A-Za-z0-9 /_-]*:\s*)?FIXME\s*$"
|
|
105
110
|
), # FIXME as a placeholder line/field, not prose
|
|
111
|
+
re.compile(r"\?{3,}"), # ??? unresolved marker
|
|
112
|
+
re.compile(r"待定"), # zh: undecided / pending
|
|
113
|
+
re.compile(r"(?i)\bto be (?:decided|determined)\b"), # english placeholder phrase
|
|
106
114
|
]
|
|
107
115
|
|
|
108
116
|
# IDs that represent upstream references: REQ-*, RISK-*, DES-*, SPEC-*
|
|
@@ -249,7 +257,6 @@ def _has_external_docs_inventory(content: str) -> bool:
|
|
|
249
257
|
# PLAN code-block gate helpers
|
|
250
258
|
# ---------------------------------------------------------------------------
|
|
251
259
|
|
|
252
|
-
_PLAN_TASK_RE = re.compile(r"^### PLAN-TASK-\d+", re.MULTILINE)
|
|
253
260
|
_CODE_FENCE_LINE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(.*)$")
|
|
254
261
|
_INLINE_CODE_VALUE_RE = re.compile(r"^(`+)(.*?)\1$", re.DOTALL)
|
|
255
262
|
_MARKDOWN_LINK_VALUE_RE = re.compile(r"^\[([^\]]+)\]\([^)]+\)$")
|
|
@@ -621,6 +628,19 @@ def _check_plan_task_skeleton_placeholders(content: str) -> list[str]:
|
|
|
621
628
|
return issues
|
|
622
629
|
|
|
623
630
|
|
|
631
|
+
def _check_plan_task_verification_placeholders(content: str) -> list[str]:
|
|
632
|
+
issues: list[str] = []
|
|
633
|
+
for body in _iter_plan_task_bodies(content):
|
|
634
|
+
verification = _plan_task_field_body(body, "Verification")
|
|
635
|
+
if verification.strip() and any(p.search(verification) for p in _PLACEHOLDER_PATTERNS):
|
|
636
|
+
issues.append(
|
|
637
|
+
f"{_plan_task_label(body)} Verification contains an unresolved "
|
|
638
|
+
"placeholder; replace it with an objective pass/fail check "
|
|
639
|
+
"(command + expected result) before passing the gate."
|
|
640
|
+
)
|
|
641
|
+
return issues
|
|
642
|
+
|
|
643
|
+
|
|
624
644
|
def _section_bodies(content: str, heading: str) -> list[str]:
|
|
625
645
|
"""Return all bodies under `heading`, each stopping at the next same-or-higher heading."""
|
|
626
646
|
level = len(heading) - len(heading.lstrip("#"))
|
|
@@ -994,6 +1014,8 @@ def check_quality_gate(
|
|
|
994
1014
|
issues.extend(_check_spec_refs_valid(run_dir, gate_content))
|
|
995
1015
|
# R5.2b: Skeleton is fenced, so detect template placeholders there explicitly.
|
|
996
1016
|
issues.extend(_check_plan_task_skeleton_placeholders(gate_content))
|
|
1017
|
+
# R5.2c: Verification must be an objective check, not a placeholder.
|
|
1018
|
+
issues.extend(_check_plan_task_verification_placeholders(gate_content))
|
|
997
1019
|
# R5.3: file refs vs Context Pack repo_root
|
|
998
1020
|
issues.extend(_check_plan_file_refs(run_dir, gate_content))
|
|
999
1021
|
|
|
@@ -1086,3 +1108,128 @@ def check_forced_subagent_review(
|
|
|
1086
1108
|
],
|
|
1087
1109
|
exit_code=5,
|
|
1088
1110
|
)
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
# ---------------------------------------------------------------------------
|
|
1114
|
+
# Execution Completion Gate
|
|
1115
|
+
# ---------------------------------------------------------------------------
|
|
1116
|
+
|
|
1117
|
+
_LEDGER_UNCHECKED_RE = re.compile(r"^\s*-\s*\[\s*\]\s*(PLAN-TASK-\d+)\b")
|
|
1118
|
+
_LEDGER_CHECKED_RE = re.compile(r"^\s*-\s*\[[xX]\]\s*(PLAN-TASK-\d+)\b")
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _read_regular_text_no_symlink(path: Path) -> tuple[str | None, str | None]:
|
|
1122
|
+
"""Read a regular file without following a symlink where the OS supports it."""
|
|
1123
|
+
if path.parent.is_symlink() or path.is_symlink():
|
|
1124
|
+
return None, "symlink"
|
|
1125
|
+
# O_NONBLOCK so opening a non-regular file (e.g. a writerless FIFO) returns
|
|
1126
|
+
# immediately and is rejected by the S_ISREG check below instead of blocking.
|
|
1127
|
+
# It has no effect on regular-file reads.
|
|
1128
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
1129
|
+
fd: int | None = None
|
|
1130
|
+
try:
|
|
1131
|
+
fd = os.open(path, flags)
|
|
1132
|
+
mode = os.fstat(fd).st_mode
|
|
1133
|
+
if not stat.S_ISREG(mode):
|
|
1134
|
+
return None, "not_regular"
|
|
1135
|
+
with os.fdopen(fd, "r", encoding="utf-8") as fh:
|
|
1136
|
+
fd = None
|
|
1137
|
+
return fh.read(), None
|
|
1138
|
+
except FileNotFoundError:
|
|
1139
|
+
return None, "missing"
|
|
1140
|
+
except OSError as exc:
|
|
1141
|
+
if exc.errno == errno.ELOOP:
|
|
1142
|
+
return None, "symlink"
|
|
1143
|
+
raise
|
|
1144
|
+
finally:
|
|
1145
|
+
if fd is not None:
|
|
1146
|
+
os.close(fd)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _plan_task_ids(run_dir: Path) -> list[str]:
|
|
1150
|
+
"""Task IDs declared in the frozen PLAN artifact."""
|
|
1151
|
+
from tools.workflow_cli.artifact import read_artifact
|
|
1152
|
+
plan_text = read_artifact(run_dir, Stage.PLAN)
|
|
1153
|
+
return [tid for tid, _ in plan_task_anchors(strip_readonly_sections(plan_text))]
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def check_execution_complete(run_dir: Path) -> GateResult:
|
|
1157
|
+
"""Completion gate for archiving an EXECUTING run.
|
|
1158
|
+
|
|
1159
|
+
The execution ledger (execution/progress.md, seeded by run-execute-start)
|
|
1160
|
+
is the structural evidence that every PLAN-TASK was implemented. Archiving
|
|
1161
|
+
an executing run requires the ledger to exist and report every task done:
|
|
1162
|
+
|
|
1163
|
+
- missing ledger -> fail (execution never recorded progress)
|
|
1164
|
+
- any `- [ ] PLAN-TASK-*` -> fail (unfinished tasks remain)
|
|
1165
|
+
- a frozen PLAN task with no -> fail (truncated ledger dropped a task line
|
|
1166
|
+
`- [x] PLAN-TASK-*` line instead of completing it)
|
|
1167
|
+
- no `- [x] PLAN-TASK-*` at all -> fail (cleared/empty ledger asserts nothing)
|
|
1168
|
+
|
|
1169
|
+
This is structural validation only: it trusts the agent's checkbox flips
|
|
1170
|
+
(an audit gate, not a correctness gate). The CLI offers --force to override.
|
|
1171
|
+
"""
|
|
1172
|
+
ledger = run_dir / "execution" / "progress.md"
|
|
1173
|
+
ledger_text, ledger_error = _read_regular_text_no_symlink(ledger)
|
|
1174
|
+
if ledger_error == "missing":
|
|
1175
|
+
return GateResult(
|
|
1176
|
+
passed=False,
|
|
1177
|
+
issues=[
|
|
1178
|
+
"Execution ledger execution/progress.md is missing; cannot "
|
|
1179
|
+
"confirm the PLAN was executed."
|
|
1180
|
+
],
|
|
1181
|
+
exit_code=EXIT_GATE_FAIL,
|
|
1182
|
+
)
|
|
1183
|
+
if ledger_error == "symlink":
|
|
1184
|
+
return GateResult(
|
|
1185
|
+
passed=False,
|
|
1186
|
+
issues=[
|
|
1187
|
+
"Execution ledger execution/progress.md is a symlink; refusing "
|
|
1188
|
+
"to read outside the run directory."
|
|
1189
|
+
],
|
|
1190
|
+
exit_code=EXIT_GATE_FAIL,
|
|
1191
|
+
)
|
|
1192
|
+
if ledger_error == "not_regular":
|
|
1193
|
+
return GateResult(
|
|
1194
|
+
passed=False,
|
|
1195
|
+
issues=[
|
|
1196
|
+
"Execution ledger execution/progress.md is not a regular file; "
|
|
1197
|
+
"cannot confirm the PLAN was executed."
|
|
1198
|
+
],
|
|
1199
|
+
exit_code=EXIT_GATE_FAIL,
|
|
1200
|
+
)
|
|
1201
|
+
assert ledger_text is not None
|
|
1202
|
+
lines = [line for line, _, _ in unfenced_markdown_lines(ledger_text)]
|
|
1203
|
+
issues: list[str] = []
|
|
1204
|
+
for line in lines:
|
|
1205
|
+
if _LEDGER_UNCHECKED_RE.match(line):
|
|
1206
|
+
issues.append(f"Unfinished execution task: {line.strip()}")
|
|
1207
|
+
if not issues:
|
|
1208
|
+
checked_ids = {m.group(1) for line in lines if (m := _LEDGER_CHECKED_RE.match(line))}
|
|
1209
|
+
# Cross-check the ledger against the frozen PLAN: every declared PLAN-TASK
|
|
1210
|
+
# must be checked off, so a truncated ledger (a dropped task line) cannot
|
|
1211
|
+
# pass as complete. Both inputs are CLI-owned artifacts; no new trust.
|
|
1212
|
+
try:
|
|
1213
|
+
plan_task_ids = _plan_task_ids(run_dir)
|
|
1214
|
+
except FileNotFoundError:
|
|
1215
|
+
issues.append(
|
|
1216
|
+
"Frozen PLAN artifact 07-plan.md is missing; cannot cross-check "
|
|
1217
|
+
"the execution ledger."
|
|
1218
|
+
)
|
|
1219
|
+
else:
|
|
1220
|
+
for tid in plan_task_ids:
|
|
1221
|
+
if tid not in checked_ids:
|
|
1222
|
+
issues.append(
|
|
1223
|
+
f"PLAN task {tid} is not marked complete in the execution ledger "
|
|
1224
|
+
"(its '- [x] PLAN-TASK-*' line is missing)."
|
|
1225
|
+
)
|
|
1226
|
+
if not issues and not checked_ids:
|
|
1227
|
+
issues.append(
|
|
1228
|
+
"Execution ledger lists no completed PLAN-TASK entries "
|
|
1229
|
+
"('- [x] PLAN-TASK-*'); nothing to confirm."
|
|
1230
|
+
)
|
|
1231
|
+
return GateResult(
|
|
1232
|
+
passed=len(issues) == 0,
|
|
1233
|
+
issues=issues,
|
|
1234
|
+
exit_code=EXIT_GATE_FAIL if issues else 0,
|
|
1235
|
+
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
InstallService — multi-platform install/uninstall for the r2p skill.
|
|
3
3
|
|
|
4
|
-
Supports: claude, codex, gemini
|
|
4
|
+
Supports: claude, codex, gemini, opencode
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
@@ -25,12 +25,13 @@ from tools.workflow_cli.version import R2P_VERSION
|
|
|
25
25
|
|
|
26
26
|
SCHEMA_VERSION = 1
|
|
27
27
|
|
|
28
|
-
SUPPORTED_PLATFORMS = ("claude", "codex", "gemini")
|
|
28
|
+
SUPPORTED_PLATFORMS = ("claude", "codex", "gemini", "opencode")
|
|
29
29
|
|
|
30
30
|
DEFAULT_PLATFORM_HOMES = {
|
|
31
31
|
"claude": Path.home() / ".claude",
|
|
32
32
|
"codex": Path.home() / ".codex",
|
|
33
33
|
"gemini": Path.home() / ".gemini",
|
|
34
|
+
"opencode": Path.home() / ".config" / "opencode",
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
KNOWN_OBSOLETE_SHARED_WRAPPERS = frozenset({"r2p-adapt"})
|
|
@@ -177,6 +178,23 @@ class InstallService:
|
|
|
177
178
|
dest, content, backups, installed_paths, written, backup_dir
|
|
178
179
|
)
|
|
179
180
|
|
|
181
|
+
elif platform == "opencode":
|
|
182
|
+
# opencode custom commands use Markdown files with `description:`
|
|
183
|
+
# frontmatter and the filename as the command name. Reuse claude's
|
|
184
|
+
# command bodies, then inject opencode's argument placeholder so
|
|
185
|
+
# slash-command invocation args are not dropped.
|
|
186
|
+
cmd_dir = self._opencode_command_source()
|
|
187
|
+
for src in sorted(cmd_dir.glob("r2p-*.md")):
|
|
188
|
+
dest = platform_home / "commands" / src.name
|
|
189
|
+
content = _render_opencode_command(
|
|
190
|
+
src.read_text(),
|
|
191
|
+
R2P_VERSION,
|
|
192
|
+
str(bin_dir),
|
|
193
|
+
)
|
|
194
|
+
self._write_managed_file(
|
|
195
|
+
dest, content, backups, installed_paths, written, backup_dir
|
|
196
|
+
)
|
|
197
|
+
|
|
180
198
|
# Write manifest
|
|
181
199
|
manifest: dict[str, Any] = {
|
|
182
200
|
"backups": backups,
|
|
@@ -188,7 +206,13 @@ class InstallService:
|
|
|
188
206
|
}
|
|
189
207
|
self._validate_install_path(manifest_path, field="manifest")
|
|
190
208
|
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
-
manifest_path.
|
|
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)
|
|
192
216
|
manifest_written = True
|
|
193
217
|
|
|
194
218
|
# Remove obsolete managed shared wrappers (e.g. a 0.1.2 r2p-adapt) that
|
|
@@ -556,9 +580,24 @@ class InstallService:
|
|
|
556
580
|
elif platform == "gemini":
|
|
557
581
|
for src in sorted((template_dir / "commands").glob("r2p-*.toml")):
|
|
558
582
|
targets.append(platform_home / "commands" / src.name)
|
|
583
|
+
elif platform == "opencode":
|
|
584
|
+
# opencode reuses claude's command templates (see install()).
|
|
585
|
+
for src in sorted(self._opencode_command_source().glob("r2p-*.md")):
|
|
586
|
+
targets.append(platform_home / "commands" / src.name)
|
|
559
587
|
|
|
560
588
|
return targets
|
|
561
589
|
|
|
590
|
+
def _opencode_command_source(self) -> Path:
|
|
591
|
+
"""Source dir for opencode commands, shared with claude to avoid drift."""
|
|
592
|
+
return (
|
|
593
|
+
self.repo_root
|
|
594
|
+
/ "tools"
|
|
595
|
+
/ "workflow_cli"
|
|
596
|
+
/ "agent_templates"
|
|
597
|
+
/ "claude"
|
|
598
|
+
/ "commands"
|
|
599
|
+
)
|
|
600
|
+
|
|
562
601
|
def _cleanup_obsolete_managed_wrappers(
|
|
563
602
|
self, preserve_paths: set[str] | None = None
|
|
564
603
|
) -> None:
|
|
@@ -852,6 +891,20 @@ def _render(content: str, version: str, bin_dir: str) -> str:
|
|
|
852
891
|
return content
|
|
853
892
|
|
|
854
893
|
|
|
894
|
+
def _render_opencode_command(content: str, version: str, bin_dir: str) -> str:
|
|
895
|
+
"""Render a Markdown command with opencode invocation arguments preserved."""
|
|
896
|
+
rendered = _render(content, version, bin_dir)
|
|
897
|
+
if "$ARGUMENTS" in rendered:
|
|
898
|
+
return rendered
|
|
899
|
+
return (
|
|
900
|
+
rendered.rstrip()
|
|
901
|
+
+ "\n\n## opencode invocation arguments\n\n"
|
|
902
|
+
+ "Use these arguments when running the wrapper above:\n\n"
|
|
903
|
+
+ "```text\n$ARGUMENTS\n```\n\n"
|
|
904
|
+
+ "If no arguments were supplied, follow the default usage.\n"
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
|
|
855
908
|
def _render_bin_script(content: str, repo_root: Path) -> str:
|
|
856
909
|
"""Render an installed wrapper so it imports modules from the source repo."""
|
|
857
910
|
return content.replace(
|
|
@@ -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
|
|
@@ -130,6 +130,24 @@ def heading_level(line: str) -> int | None:
|
|
|
130
130
|
return len(stripped) - len(stripped.lstrip("#"))
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
# A PLAN-TASK anchor heading, e.g. "### PLAN-TASK-001: title". Single source of
|
|
134
|
+
# truth shared by gates (task iteration) and cli (ledger seeding).
|
|
135
|
+
PLAN_TASK_ANCHOR_RE = re.compile(r"^### PLAN-TASK-\d+", re.MULTILINE)
|
|
136
|
+
_PLAN_TASK_ANCHOR_LINE_RE = re.compile(r"^###\s+(PLAN-TASK-\d+)\s*:?\s*(.*?)\s*$")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def plan_task_anchors(content: str) -> list[tuple[str, str]]:
|
|
140
|
+
"""Return (PLAN-TASK-NNN, title) for each task anchor outside code fences."""
|
|
141
|
+
anchors: list[tuple[str, str]] = []
|
|
142
|
+
for line, _, _ in unfenced_markdown_lines(content):
|
|
143
|
+
if not PLAN_TASK_ANCHOR_RE.match(line):
|
|
144
|
+
continue
|
|
145
|
+
m = _PLAN_TASK_ANCHOR_LINE_RE.match(line.rstrip("\n"))
|
|
146
|
+
if m:
|
|
147
|
+
anchors.append((m.group(1), m.group(2).strip()))
|
|
148
|
+
return anchors
|
|
149
|
+
|
|
150
|
+
|
|
133
151
|
def heading_bounded_bodies(content: str, is_start):
|
|
134
152
|
"""Yield each section whose heading line satisfies `is_start(line)`.
|
|
135
153
|
|
|
@@ -31,6 +31,8 @@ class RunStatus(str, Enum):
|
|
|
31
31
|
CHECKPOINT_APPROVED = "checkpoint_approved"
|
|
32
32
|
NEXT_STAGE = "next_stage"
|
|
33
33
|
CLOSED_AT_PLAN_CHECKPOINT = "closed_at_plan_checkpoint"
|
|
34
|
+
EXECUTING = "executing" # PLAN closed; implementing tasks in place
|
|
35
|
+
ARCHIVED = "archived" # terminal: requirement directory archived
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
class Stage(str, Enum):
|
|
@@ -148,7 +150,13 @@ ALLOWED_TRANSITIONS: dict[RunStatus, set[RunStatus]] = {
|
|
|
148
150
|
RunStatus.ACTIVE_STAGE_DRAFT,
|
|
149
151
|
RunStatus.ENTRY_GATE_FAILED,
|
|
150
152
|
},
|
|
151
|
-
RunStatus.CLOSED_AT_PLAN_CHECKPOINT:
|
|
153
|
+
RunStatus.CLOSED_AT_PLAN_CHECKPOINT: {RunStatus.EXECUTING, RunStatus.ARCHIVED},
|
|
154
|
+
RunStatus.EXECUTING: {
|
|
155
|
+
RunStatus.EXECUTING,
|
|
156
|
+
RunStatus.CLOSED_AT_PLAN_CHECKPOINT,
|
|
157
|
+
RunStatus.ARCHIVED,
|
|
158
|
+
},
|
|
159
|
+
RunStatus.ARCHIVED: set(),
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
|
|
@@ -268,8 +276,18 @@ ALLOWED_COMMANDS_BY_RUN_STATE: dict[RunStatus, set[str]] = {
|
|
|
268
276
|
},
|
|
269
277
|
RunStatus.CLOSED_AT_PLAN_CHECKPOINT: {
|
|
270
278
|
"CMD-RUN-REOPEN",
|
|
279
|
+
"CMD-RUN-EXECUTE-START",
|
|
280
|
+
"CMD-RUN-ARCHIVE",
|
|
281
|
+
"CMD-TIER-STATUS",
|
|
282
|
+
},
|
|
283
|
+
RunStatus.EXECUTING: {
|
|
284
|
+
"CMD-RUN-REOPEN",
|
|
285
|
+
"CMD-RUN-ARCHIVE",
|
|
271
286
|
"CMD-TIER-STATUS",
|
|
272
287
|
},
|
|
288
|
+
# ARCHIVED is terminal: only the always-allowed read-only commands
|
|
289
|
+
# (see READ_ONLY_COMMANDS) remain reachable.
|
|
290
|
+
RunStatus.ARCHIVED: set(),
|
|
273
291
|
}
|
|
274
292
|
|
|
275
293
|
|
|
@@ -47,7 +47,8 @@ _HEADING_BODY = {
|
|
|
47
47
|
"```\n"
|
|
48
48
|
"Steps:\n"
|
|
49
49
|
"- [ ] <!-- fill in -->\n"
|
|
50
|
-
"Verification: <!-- fill in
|
|
50
|
+
"Verification: <!-- fill in: objective pass/fail check (command + expected result), "
|
|
51
|
+
"e.g. `pytest tests/x.py::test_y` passes / `GET /foo` returns 429 when over limit -->\n"
|
|
51
52
|
),
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -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.
|
|
1
|
+
R2P_VERSION = "0.5.0"
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Workspace-level helpers for the `.req-to-plan/` directory.
|
|
2
|
+
|
|
3
|
+
Neutral module imported by both cli.py and agent_shortcuts.py (it imports
|
|
4
|
+
neither, so there is no cycle). Owns the workspace `.gitignore` and the
|
|
5
|
+
path-limited git-commit primitive used by run-close (add) and run-archive
|
|
6
|
+
(remove).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from tools.workflow_cli.atomic import atomic_write_text
|
|
16
|
+
|
|
17
|
+
_WORKSPACE_GITIGNORE_LINES = ("/archive", "/.workflow-active")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_workspace_gitignore(base_path: Path) -> None:
|
|
21
|
+
"""Ensure `<base>/.req-to-plan/.gitignore` ignores local workspace files.
|
|
22
|
+
|
|
23
|
+
Creates the file with required entries if absent; appends missing lines if
|
|
24
|
+
the file exists; no-op if already complete. Refuses symlinks so this helper
|
|
25
|
+
never reads external target contents into the workspace file.
|
|
26
|
+
"""
|
|
27
|
+
r2p_dir = base_path / ".req-to-plan"
|
|
28
|
+
if r2p_dir.is_symlink():
|
|
29
|
+
raise ValueError("unsafe_workspace_dir_symlink")
|
|
30
|
+
r2p_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
if r2p_dir.is_symlink():
|
|
32
|
+
raise ValueError("unsafe_workspace_dir_symlink")
|
|
33
|
+
gitignore = r2p_dir / ".gitignore"
|
|
34
|
+
if gitignore.is_symlink():
|
|
35
|
+
raise ValueError("unsafe_workspace_gitignore_symlink")
|
|
36
|
+
if not gitignore.exists():
|
|
37
|
+
atomic_write_text(gitignore, "".join(f"{line}\n" for line in _WORKSPACE_GITIGNORE_LINES))
|
|
38
|
+
return
|
|
39
|
+
existing = gitignore.read_text(encoding="utf-8")
|
|
40
|
+
existing_lines = [ln.strip() for ln in existing.splitlines()]
|
|
41
|
+
missing_lines = [line for line in _WORKSPACE_GITIGNORE_LINES if line not in existing_lines]
|
|
42
|
+
if not missing_lines:
|
|
43
|
+
return
|
|
44
|
+
prefix = existing if existing.endswith("\n") or existing == "" else existing + "\n"
|
|
45
|
+
atomic_write_text(gitignore, prefix + "".join(f"{line}\n" for line in missing_lines))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _git(base_path: Path, *args: str) -> subprocess.CompletedProcess:
|
|
49
|
+
return subprocess.run(
|
|
50
|
+
["git", "-C", str(base_path), *args],
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _warn(message: str) -> None:
|
|
57
|
+
print(f"warning: {message}", file=sys.stderr)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def commit_requirement_dir(base_path: Path, work_id: str, message: str) -> None:
|
|
61
|
+
"""Path-limited, best-effort commit of one requirement directory.
|
|
62
|
+
|
|
63
|
+
Stages and commits only `.req-to-plan/.gitignore` and `.req-to-plan/<id>`.
|
|
64
|
+
The same primitive both adds (path present) and removes (path moved/deleted).
|
|
65
|
+
Best-effort with observable skips; it never raises and never touches paths
|
|
66
|
+
outside `.req-to-plan/<id>` (no `add -A`, no `-f`, no push).
|
|
67
|
+
"""
|
|
68
|
+
gitignore_rel = ".req-to-plan/.gitignore"
|
|
69
|
+
run_rel = f".req-to-plan/{work_id}"
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
# Guard 1: must be inside a git work tree.
|
|
73
|
+
inside = _git(base_path, "rev-parse", "--is-inside-work-tree")
|
|
74
|
+
if inside.returncode != 0 or inside.stdout.strip() != "true":
|
|
75
|
+
_warn(f"skipped commit for {run_rel}: not a git work tree")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
# Stage (path-limited). `git add -- <path>` stages an addition/modification
|
|
79
|
+
# when the path exists, and stages a deletion when it was tracked and is now
|
|
80
|
+
# gone. A wholesale-gitignored path stages nothing (we never pass -f).
|
|
81
|
+
_git(base_path, "add", "--", gitignore_rel, run_rel)
|
|
82
|
+
|
|
83
|
+
# Guards 2 & 3 (merged): if nothing is staged for these paths, there is
|
|
84
|
+
# nothing to commit — covers wholesale-ignored, untracked-then-moved, and
|
|
85
|
+
# no-change. `git diff --cached --quiet` returns 0 when there is no diff.
|
|
86
|
+
staged = _git(base_path, "diff", "--cached", "--quiet", "--", gitignore_rel, run_rel)
|
|
87
|
+
if staged.returncode == 0:
|
|
88
|
+
_warn(f"skipped commit for {run_rel}: nothing staged (ignored or unchanged)")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
# `--no-verify` does not skip post-commit, so disable hooks for this
|
|
92
|
+
# best-effort background commit by pointing hooksPath at an empty dir.
|
|
93
|
+
with tempfile.TemporaryDirectory(prefix="r2p-empty-hooks-") as hooks_path:
|
|
94
|
+
committed = _git(
|
|
95
|
+
base_path,
|
|
96
|
+
"-c",
|
|
97
|
+
f"core.hooksPath={hooks_path}",
|
|
98
|
+
"commit",
|
|
99
|
+
"--no-verify",
|
|
100
|
+
"-m",
|
|
101
|
+
message,
|
|
102
|
+
"--",
|
|
103
|
+
gitignore_rel,
|
|
104
|
+
run_rel,
|
|
105
|
+
)
|
|
106
|
+
if committed.returncode != 0:
|
|
107
|
+
unstaged = _git(base_path, "reset", "-q", "--", gitignore_rel, run_rel)
|
|
108
|
+
_warn(f"git commit failed for {run_rel}: {committed.stderr.strip()}")
|
|
109
|
+
if unstaged.returncode != 0:
|
|
110
|
+
_warn(f"failed to unstage {run_rel}: {unstaged.stderr.strip()}")
|
|
111
|
+
except FileNotFoundError:
|
|
112
|
+
_warn(f"skipped commit for {run_rel}: git executable not found")
|