@xenonbyte/req-2-plan 0.4.5 → 0.5.1
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 +295 -21
- 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/atomic.py +6 -1
- package/tools/workflow_cli/cli.py +224 -21
- package/tools/workflow_cli/gates.py +149 -2
- package/tools/workflow_cli/install.py +49 -2
- 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/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,
|
|
@@ -562,9 +580,24 @@ class InstallService:
|
|
|
562
580
|
elif platform == "gemini":
|
|
563
581
|
for src in sorted((template_dir / "commands").glob("r2p-*.toml")):
|
|
564
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)
|
|
565
587
|
|
|
566
588
|
return targets
|
|
567
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
|
+
|
|
568
601
|
def _cleanup_obsolete_managed_wrappers(
|
|
569
602
|
self, preserve_paths: set[str] | None = None
|
|
570
603
|
) -> None:
|
|
@@ -858,6 +891,20 @@ def _render(content: str, version: str, bin_dir: str) -> str:
|
|
|
858
891
|
return content
|
|
859
892
|
|
|
860
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
|
+
|
|
861
908
|
def _render_bin_script(content: str, repo_root: Path) -> str:
|
|
862
909
|
"""Render an installed wrapper so it imports modules from the source repo."""
|
|
863
910
|
return content.replace(
|
|
@@ -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
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
R2P_VERSION = "0.
|
|
1
|
+
R2P_VERSION = "0.5.1"
|
|
@@ -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")
|