@xenonbyte/req-2-plan 0.2.3
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/LICENSE +21 -0
- package/README.md +172 -0
- package/README.zh-CN.md +158 -0
- package/bin/r2p.js +38 -0
- package/docs/req-to-plan-design.md +277 -0
- package/package.json +47 -0
- package/requirements.txt +1 -0
- package/tools/r2p +10 -0
- package/tools/r2p-continue +10 -0
- package/tools/r2p-gap-open +10 -0
- package/tools/r2p-gap-resolve +10 -0
- package/tools/r2p-reopen +10 -0
- package/tools/r2p-start +10 -0
- package/tools/r2p-status +10 -0
- package/tools/r2p-switch +10 -0
- package/tools/r2p-tier-lock +10 -0
- package/tools/workflow_cli/__init__.py +0 -0
- package/tools/workflow_cli/__main__.py +5 -0
- package/tools/workflow_cli/agent_shortcuts.py +778 -0
- package/tools/workflow_cli/agent_templates/claude/SKILL.md +34 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +16 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-open.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-resolve.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +10 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-status.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-switch.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-tier-lock.md +8 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-open/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-resolve/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +14 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-status/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-switch/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-tier-lock/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-open.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-resolve.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-status.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-switch.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-tier-lock.toml +4 -0
- package/tools/workflow_cli/artifact.py +228 -0
- package/tools/workflow_cli/cli.py +1779 -0
- package/tools/workflow_cli/gates.py +471 -0
- package/tools/workflow_cli/install.py +900 -0
- package/tools/workflow_cli/install_cli.py +158 -0
- package/tools/workflow_cli/link_expander.py +102 -0
- package/tools/workflow_cli/models.py +504 -0
- package/tools/workflow_cli/output.py +91 -0
- package/tools/workflow_cli/repo_baseline.py +137 -0
- package/tools/workflow_cli/state.py +621 -0
- package/tools/workflow_cli/tier.py +201 -0
- package/tools/workflow_cli/tier_keywords.yaml +45 -0
- package/tools/workflow_cli/version.py +1 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tier-aware structured gate checks for the req-to-plan workflow CLI.
|
|
3
|
+
|
|
4
|
+
The CLI runs these structural checks before checkpoint approval.
|
|
5
|
+
The Agent handles semantic quality; this module handles structural validation.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from tools.workflow_cli.models import (
|
|
14
|
+
BundleAuthorization,
|
|
15
|
+
CheckpointRecord,
|
|
16
|
+
STAGE_ARTIFACT_MAP,
|
|
17
|
+
STAGE_REQUIRED_UPSTREAM_CHECKPOINTS,
|
|
18
|
+
Stage,
|
|
19
|
+
TierEstimate,
|
|
20
|
+
TierModifier,
|
|
21
|
+
)
|
|
22
|
+
from tools.workflow_cli.output import EXIT_GATE_FAIL
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# GateResult
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class GateResult:
|
|
30
|
+
passed: bool
|
|
31
|
+
issues: list[str]
|
|
32
|
+
exit_code: int = 0 # 0=pass, 2=entry failure, 3=quality failure, 5=forced-subagent-review-required
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Entry Gate
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def check_entry_gate(
|
|
40
|
+
run_dir: Path,
|
|
41
|
+
stage: Stage,
|
|
42
|
+
approved_checkpoints: list, # list[CheckpointRecord]
|
|
43
|
+
bundle_authorizations: list, # list[BundleAuthorization]
|
|
44
|
+
) -> GateResult:
|
|
45
|
+
"""Check entry gate: all required upstream artifacts are approved and present."""
|
|
46
|
+
issues: list[str] = []
|
|
47
|
+
required_stages = STAGE_REQUIRED_UPSTREAM_CHECKPOINTS.get(stage, [])
|
|
48
|
+
|
|
49
|
+
# Build set of stages covered by approved checkpoints
|
|
50
|
+
approved_stages: set[Stage] = {cp.stage for cp in approved_checkpoints}
|
|
51
|
+
|
|
52
|
+
for upstream_stage in required_stages:
|
|
53
|
+
# Check 1: is there an approval or live bundle covering this stage?
|
|
54
|
+
covered = upstream_stage in approved_stages or any(
|
|
55
|
+
ba.covers(upstream_stage) for ba in bundle_authorizations
|
|
56
|
+
)
|
|
57
|
+
if not covered:
|
|
58
|
+
issues.append(
|
|
59
|
+
f"Missing approval for upstream stage {upstream_stage.value!r}: "
|
|
60
|
+
"no approved checkpoint and no live bundle authorization."
|
|
61
|
+
)
|
|
62
|
+
continue # Skip file-existence check when no authorization exists
|
|
63
|
+
|
|
64
|
+
# Check 2: the artifact file must exist on disk
|
|
65
|
+
artifact_filename = STAGE_ARTIFACT_MAP.get(upstream_stage)
|
|
66
|
+
if artifact_filename:
|
|
67
|
+
artifact_path = run_dir / artifact_filename
|
|
68
|
+
if not artifact_path.exists():
|
|
69
|
+
issues.append(
|
|
70
|
+
f"Upstream artifact file missing for stage {upstream_stage.value!r}: "
|
|
71
|
+
f"{artifact_filename}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return GateResult(
|
|
75
|
+
passed=len(issues) == 0,
|
|
76
|
+
issues=issues,
|
|
77
|
+
exit_code=EXIT_GATE_FAIL if issues else 0,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Upstream ID reference pattern
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
# IDs that represent upstream references: REQ-*, RISK-*, DES-*, SPEC-*
|
|
86
|
+
_UPSTREAM_ID_PATTERN = re.compile(
|
|
87
|
+
r"\b(REQ-[A-Z]+-\d+|RISK-[A-Z]+-\d+|DES-[A-Z]+-\d+|SPEC-[A-Z]+-\d+)\b"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Closure status tags
|
|
91
|
+
_CLOSURE_TAGS = frozenset(["[ADDRESSED]", "[DEFERRED]", "[N/A]", "[OUT-OF-SCOPE]", "[CLOSED]"])
|
|
92
|
+
|
|
93
|
+
# IDs of form [A-Z]+-[A-Z]+-[0-9]+ (definition context: heading or line-start)
|
|
94
|
+
_DEFINED_ID_PATTERN = re.compile(r"\b([A-Z]+-[A-Z]+-\d+)\b")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _find_defined_ids(content: str) -> set[str]:
|
|
98
|
+
"""Return IDs that are defined in headings (i.e. the current artifact is defining them)."""
|
|
99
|
+
heading_pattern = re.compile(r"^#{1,6}\s+.*\b([A-Z]+-[A-Z]+-\d+)\b", re.MULTILINE)
|
|
100
|
+
return set(heading_pattern.findall(content))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _find_ids_without_closure(content: str) -> list[str]:
|
|
104
|
+
"""Return upstream IDs referenced in content that have no closure tag.
|
|
105
|
+
|
|
106
|
+
IDs defined in headings of the current artifact are excluded — they are
|
|
107
|
+
being *defined* here, not referencing upstream artifacts that need closure.
|
|
108
|
+
"""
|
|
109
|
+
all_refs = set(_UPSTREAM_ID_PATTERN.findall(content))
|
|
110
|
+
defined_here = _find_defined_ids(content)
|
|
111
|
+
# Only check IDs that are referenced but NOT defined in this artifact
|
|
112
|
+
refs_to_check = all_refs - defined_here
|
|
113
|
+
unclosed: list[str] = []
|
|
114
|
+
for ref_id in sorted(refs_to_check):
|
|
115
|
+
# Look for the ID followed (anywhere on the same token-group) by a closure tag
|
|
116
|
+
# We search for patterns like: ID [TAG] anywhere in content
|
|
117
|
+
has_closure = False
|
|
118
|
+
for tag in _CLOSURE_TAGS:
|
|
119
|
+
# Allow flexible spacing between ID and tag on the same general vicinity
|
|
120
|
+
pattern = re.compile(
|
|
121
|
+
re.escape(ref_id) + r"[^\n]*" + re.escape(tag),
|
|
122
|
+
re.IGNORECASE,
|
|
123
|
+
)
|
|
124
|
+
if pattern.search(content):
|
|
125
|
+
has_closure = True
|
|
126
|
+
break
|
|
127
|
+
if not has_closure:
|
|
128
|
+
unclosed.append(ref_id)
|
|
129
|
+
return unclosed
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _find_duplicate_ids(content: str) -> list[str]:
|
|
133
|
+
"""Return IDs that appear more than once in heading (definition) context."""
|
|
134
|
+
heading_pattern = re.compile(r"^#{1,6}\s+.*\b([A-Z]+-[A-Z]+-\d+)\b", re.MULTILINE)
|
|
135
|
+
heading_ids = heading_pattern.findall(content)
|
|
136
|
+
|
|
137
|
+
from collections import Counter
|
|
138
|
+
counts = Counter(heading_ids)
|
|
139
|
+
return [id_ for id_, count in counts.items() if count > 1]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# SPEC External Documentation Checked helpers
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
_EXTERNAL_DOCS_RE = re.compile(r"^## External Documentation Checked\s*$", re.MULTILINE)
|
|
147
|
+
_H2_RE = re.compile(r"^##\s+", re.MULTILINE)
|
|
148
|
+
_MARKDOWN_CODE_FENCE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})", re.MULTILINE)
|
|
149
|
+
_MARKDOWN_FENCE_MARKER_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})")
|
|
150
|
+
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _unfenced_markdown_lines(content: str):
|
|
154
|
+
"""Yield (line, start, end) for lines outside Markdown fenced code blocks."""
|
|
155
|
+
fence_char = ""
|
|
156
|
+
fence_len = 0
|
|
157
|
+
offset = 0
|
|
158
|
+
for line in content.splitlines(keepends=True):
|
|
159
|
+
marker = _MARKDOWN_FENCE_MARKER_RE.match(line)
|
|
160
|
+
if fence_char:
|
|
161
|
+
if (
|
|
162
|
+
marker
|
|
163
|
+
and marker.group(1)[0] == fence_char
|
|
164
|
+
and len(marker.group(1)) >= fence_len
|
|
165
|
+
and not line[marker.end():].strip()
|
|
166
|
+
):
|
|
167
|
+
fence_char = ""
|
|
168
|
+
fence_len = 0
|
|
169
|
+
offset += len(line)
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
if marker:
|
|
173
|
+
fence_char = marker.group(1)[0]
|
|
174
|
+
fence_len = len(marker.group(1))
|
|
175
|
+
offset += len(line)
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
start = offset
|
|
179
|
+
offset += len(line)
|
|
180
|
+
yield line, start, offset
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _plain_table_cell(cell: str) -> str:
|
|
184
|
+
return cell.strip().strip("_*`").strip()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_external_docs_inventory_row(line: str) -> bool:
|
|
188
|
+
if line == "N/A — no external dependencies":
|
|
189
|
+
return True
|
|
190
|
+
if not (line.startswith("|") and line.endswith("|")):
|
|
191
|
+
return False
|
|
192
|
+
cells = [cell.strip() for cell in line.strip("|").split("|")]
|
|
193
|
+
if len(cells) != 4:
|
|
194
|
+
return False
|
|
195
|
+
if [cell.lower() for cell in cells] == ["dependency", "version", "check date", "conclusion"]:
|
|
196
|
+
return False
|
|
197
|
+
if all(set(cell) <= {"-", ":", " "} for cell in cells):
|
|
198
|
+
return False
|
|
199
|
+
if not all(cells):
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
dependency, version, check_date, conclusion = [_plain_table_cell(cell) for cell in cells]
|
|
203
|
+
normalized = [cell.lower() for cell in (dependency, version, check_date, conclusion)]
|
|
204
|
+
if normalized == ["example", "x.y", "yyyy-mm-dd", "context7 checked / unconfirmed"]:
|
|
205
|
+
return False
|
|
206
|
+
if dependency.lower() == "example":
|
|
207
|
+
return False
|
|
208
|
+
if version.lower() == "x.y":
|
|
209
|
+
return False
|
|
210
|
+
if check_date.lower() == "yyyy-mm-dd":
|
|
211
|
+
return False
|
|
212
|
+
if conclusion.lower() == "context7 checked / unconfirmed":
|
|
213
|
+
return False
|
|
214
|
+
if not _ISO_DATE_RE.fullmatch(check_date):
|
|
215
|
+
return False
|
|
216
|
+
return bool(dependency and version and conclusion)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _has_external_docs_inventory(content: str) -> bool:
|
|
220
|
+
section_start = None
|
|
221
|
+
for line, _, end in _unfenced_markdown_lines(content):
|
|
222
|
+
if _EXTERNAL_DOCS_RE.match(line):
|
|
223
|
+
section_start = end
|
|
224
|
+
break
|
|
225
|
+
if section_start is None:
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
for line, start, _ in _unfenced_markdown_lines(content):
|
|
229
|
+
if start < section_start:
|
|
230
|
+
continue
|
|
231
|
+
if _H2_RE.match(line):
|
|
232
|
+
break
|
|
233
|
+
stripped = line.strip()
|
|
234
|
+
if stripped and _is_external_docs_inventory_row(stripped):
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# PLAN code-block gate helpers
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
_PLAN_TASK_RE = re.compile(r"^### PLAN-TASK-\d+", re.MULTILINE)
|
|
244
|
+
_CODE_FENCE_LINE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(.*)$")
|
|
245
|
+
_PLAN_TASK_FIELD_RE = re.compile(
|
|
246
|
+
r"^(Spec References|Change Type|TDD Applicable|Files|Skeleton|Steps|Verification):"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _plan_task_starts(content: str) -> list[int]:
|
|
251
|
+
return [
|
|
252
|
+
start
|
|
253
|
+
for line, start, _ in _unfenced_markdown_lines(content)
|
|
254
|
+
if _PLAN_TASK_RE.match(line)
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _plan_task_field_body(task_body: str, field: str) -> str:
|
|
259
|
+
found = _find_plan_task_field(task_body, field)
|
|
260
|
+
if found is None:
|
|
261
|
+
return ""
|
|
262
|
+
match, line_start = found
|
|
263
|
+
body_start = line_start + match.end()
|
|
264
|
+
next_start = _find_next_plan_task_field_start(task_body, body_start)
|
|
265
|
+
end = next_start if next_start is not None else len(task_body)
|
|
266
|
+
return f"{match.group(1)}\n{task_body[body_start:end]}"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _find_plan_task_field(task_body: str, field: str):
|
|
270
|
+
field_re = re.compile(rf"^{re.escape(field)}:[ \t]*(.*)$")
|
|
271
|
+
for line, start, _ in _unfenced_markdown_lines(task_body):
|
|
272
|
+
match = field_re.match(line)
|
|
273
|
+
if match:
|
|
274
|
+
return match, start
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _find_next_plan_task_field_start(task_body: str, after: int) -> int | None:
|
|
279
|
+
for line, start, _ in _unfenced_markdown_lines(task_body):
|
|
280
|
+
if start >= after and _PLAN_TASK_FIELD_RE.match(line):
|
|
281
|
+
return start
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _plan_task_field_value(task_body: str, field: str) -> str:
|
|
286
|
+
found = _find_plan_task_field(task_body, field)
|
|
287
|
+
if found is None:
|
|
288
|
+
return ""
|
|
289
|
+
match, _ = found
|
|
290
|
+
return match.group(1).strip()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _has_complete_code_fence(content: str) -> bool:
|
|
294
|
+
fence_char = ""
|
|
295
|
+
fence_len = 0
|
|
296
|
+
has_body = False
|
|
297
|
+
|
|
298
|
+
for line in content.splitlines():
|
|
299
|
+
marker = _CODE_FENCE_LINE_RE.match(line)
|
|
300
|
+
if fence_char:
|
|
301
|
+
if (
|
|
302
|
+
marker
|
|
303
|
+
and marker.group(1)[0] == fence_char
|
|
304
|
+
and len(marker.group(1)) >= fence_len
|
|
305
|
+
and not marker.group(2).strip()
|
|
306
|
+
):
|
|
307
|
+
if has_body:
|
|
308
|
+
return True
|
|
309
|
+
fence_char = ""
|
|
310
|
+
fence_len = 0
|
|
311
|
+
has_body = False
|
|
312
|
+
continue
|
|
313
|
+
if line.strip():
|
|
314
|
+
has_body = True
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
if marker and marker.group(2).strip():
|
|
318
|
+
fence_char = marker.group(1)[0]
|
|
319
|
+
fence_len = len(marker.group(1))
|
|
320
|
+
has_body = False
|
|
321
|
+
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _plan_tasks_missing_code(content: str) -> bool:
|
|
326
|
+
"""True if any TDD-applicable PLAN-TASK has no fenced code block in its Skeleton field."""
|
|
327
|
+
starts = _plan_task_starts(content)
|
|
328
|
+
if not starts:
|
|
329
|
+
return False
|
|
330
|
+
bounds = starts + [len(content)]
|
|
331
|
+
for i in range(len(starts)):
|
|
332
|
+
body = content[bounds[i]:bounds[i + 1]]
|
|
333
|
+
skeleton = _plan_task_field_body(body, "Skeleton")
|
|
334
|
+
tdd_applicable = _plan_task_field_value(body, "TDD Applicable")
|
|
335
|
+
if tdd_applicable.lower() == "yes" and not _has_complete_code_fence(skeleton):
|
|
336
|
+
return True
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# Quality Gate
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
def check_quality_gate(
|
|
345
|
+
run_dir: Path,
|
|
346
|
+
stage: Stage,
|
|
347
|
+
tier: TierEstimate | None,
|
|
348
|
+
approved_checkpoints: list, # list[CheckpointRecord]
|
|
349
|
+
artifact_content: str,
|
|
350
|
+
) -> GateResult:
|
|
351
|
+
"""Check quality gate: tier-aware structural content checks."""
|
|
352
|
+
# Check 1: tier must be locked
|
|
353
|
+
if tier is None:
|
|
354
|
+
return GateResult(
|
|
355
|
+
passed=False,
|
|
356
|
+
issues=["Tier not locked; run tier-lock before quality gate"],
|
|
357
|
+
exit_code=3,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
issues: list[str] = []
|
|
361
|
+
|
|
362
|
+
# Check 2: content must be non-empty
|
|
363
|
+
if not artifact_content or not artifact_content.strip():
|
|
364
|
+
issues.append("Artifact content is empty or whitespace-only.")
|
|
365
|
+
|
|
366
|
+
if not issues:
|
|
367
|
+
# Check 3: upstream reference coverage closure (all tiers)
|
|
368
|
+
unclosed = _find_ids_without_closure(artifact_content)
|
|
369
|
+
for ref_id in unclosed:
|
|
370
|
+
issues.append(
|
|
371
|
+
f"Upstream reference {ref_id!r} appears in artifact but has no closure status tag "
|
|
372
|
+
f"([ADDRESSED], [DEFERRED], [N/A], [OUT-OF-SCOPE], or [CLOSED])."
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Check 4: ID uniqueness within artifact
|
|
376
|
+
duplicates = _find_duplicate_ids(artifact_content)
|
|
377
|
+
for dup_id in duplicates:
|
|
378
|
+
issues.append(
|
|
379
|
+
f"Duplicate ID definition {dup_id!r} found in artifact; each ID must be unique."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Check 5 (PLAN, standard tier): TDD-applicable tasks must carry a code block.
|
|
383
|
+
from tools.workflow_cli.models import TierBase
|
|
384
|
+
if stage == Stage.PLAN and tier.base == TierBase.STANDARD:
|
|
385
|
+
if not _plan_task_starts(artifact_content):
|
|
386
|
+
issues.append(
|
|
387
|
+
"PLAN is missing '### PLAN-TASK-*' sections; standard tier requires "
|
|
388
|
+
"machine-parseable executable anchors."
|
|
389
|
+
)
|
|
390
|
+
elif _plan_tasks_missing_code(artifact_content):
|
|
391
|
+
issues.append(
|
|
392
|
+
"PLAN has a 'TDD Applicable: yes' task with no fenced code block; "
|
|
393
|
+
"add a Skeleton code block (standard tier requires executable anchors)."
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Check 6 (SPEC): the External Documentation Checked section must be present and non-empty.
|
|
397
|
+
if stage == Stage.SPEC:
|
|
398
|
+
if not _has_external_docs_inventory(artifact_content):
|
|
399
|
+
issues.append(
|
|
400
|
+
"SPEC is missing a non-empty '## External Documentation Checked' section. "
|
|
401
|
+
"Add it; if there are no external dependencies, include an explicit "
|
|
402
|
+
"'N/A — no external dependencies' row."
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
return GateResult(
|
|
406
|
+
passed=len(issues) == 0,
|
|
407
|
+
issues=issues,
|
|
408
|
+
exit_code=3 if issues else 0,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# Forced Subagent Review Check
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
_FORCED_REVIEW_MODIFIERS: frozenset[TierModifier] = frozenset({
|
|
417
|
+
TierModifier.MIGRATION,
|
|
418
|
+
TierModifier.SAFETY,
|
|
419
|
+
TierModifier.CROSS_PROJECT,
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
_FORCED_REVIEW_STAGES: frozenset[Stage] = frozenset({
|
|
423
|
+
Stage.DESIGN,
|
|
424
|
+
Stage.SPEC,
|
|
425
|
+
Stage.PLAN,
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def check_forced_subagent_review(
|
|
430
|
+
stage: Stage,
|
|
431
|
+
tier: TierEstimate | None,
|
|
432
|
+
reviews_dir: Path,
|
|
433
|
+
version: int = 1,
|
|
434
|
+
) -> GateResult:
|
|
435
|
+
"""
|
|
436
|
+
Refuse with exit_code=5 when ALL conditions hold:
|
|
437
|
+
- tier is not None
|
|
438
|
+
- tier.modifiers intersects {MIGRATION, SAFETY, CROSS_PROJECT}
|
|
439
|
+
- stage is in {DESIGN, SPEC, PLAN}
|
|
440
|
+
- no version-matched subagent review file exists under reviews_dir for this stage
|
|
441
|
+
"""
|
|
442
|
+
# Condition 1: tier must exist
|
|
443
|
+
if tier is None:
|
|
444
|
+
return GateResult(passed=True, issues=[], exit_code=0)
|
|
445
|
+
|
|
446
|
+
# Condition 2: modifiers must intersect forced-review set
|
|
447
|
+
if not (tier.modifiers & _FORCED_REVIEW_MODIFIERS):
|
|
448
|
+
return GateResult(passed=True, issues=[], exit_code=0)
|
|
449
|
+
|
|
450
|
+
# Condition 3: stage must be in forced-review stages
|
|
451
|
+
if stage not in _FORCED_REVIEW_STAGES:
|
|
452
|
+
return GateResult(passed=True, issues=[], exit_code=0)
|
|
453
|
+
|
|
454
|
+
# Condition 4: a real, version-matched subagent review must exist.
|
|
455
|
+
stage_name = stage.value
|
|
456
|
+
subagent_file = reviews_dir / f"{stage_name}-subagent-review-v{version}.md"
|
|
457
|
+
if subagent_file.exists():
|
|
458
|
+
return GateResult(passed=True, issues=[], exit_code=0)
|
|
459
|
+
|
|
460
|
+
# All conditions met: require forced subagent review
|
|
461
|
+
triggering = sorted(m.value for m in tier.modifiers & _FORCED_REVIEW_MODIFIERS)
|
|
462
|
+
return GateResult(
|
|
463
|
+
passed=False,
|
|
464
|
+
issues=[
|
|
465
|
+
f"Forced subagent review required for stage {stage.value!r} "
|
|
466
|
+
f"with modifier(s) {triggering!r}. "
|
|
467
|
+
f"Run a review subagent and write its findings to {subagent_file} "
|
|
468
|
+
f"(the filename must match {subagent_file.name!r}), then retry checkpoint approval."
|
|
469
|
+
],
|
|
470
|
+
exit_code=5,
|
|
471
|
+
)
|