@xenonbyte/req-2-plan 0.2.3 → 0.4.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 +11 -4
- package/README.zh-CN.md +10 -4
- package/package.json +3 -3
- package/tools/workflow_cli/agent_shortcuts.py +57 -6
- package/tools/workflow_cli/agent_templates/claude/SKILL.md +4 -2
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +2 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +2 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +1 -1
- package/tools/workflow_cli/cli.py +75 -2
- package/tools/workflow_cli/context_pack.py +98 -0
- package/tools/workflow_cli/gates.py +646 -57
- package/tools/workflow_cli/link_expander.py +28 -0
- package/tools/workflow_cli/markdown.py +160 -0
- package/tools/workflow_cli/stage_schema.py +57 -0
- package/tools/workflow_cli/stage_templates.py +66 -0
- package/tools/workflow_cli/trace.py +279 -0
- package/tools/workflow_cli/version.py +1 -1
- package/docs/req-to-plan-design.md +0 -277
|
@@ -7,6 +7,7 @@ The Agent handles semantic quality; this module handles structural validation.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import re
|
|
10
|
+
from collections import Counter
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
@@ -19,7 +20,15 @@ from tools.workflow_cli.models import (
|
|
|
19
20
|
TierEstimate,
|
|
20
21
|
TierModifier,
|
|
21
22
|
)
|
|
23
|
+
from tools.workflow_cli.markdown import (
|
|
24
|
+
heading_bounded_bodies,
|
|
25
|
+
heading_level,
|
|
26
|
+
strip_readonly_sections,
|
|
27
|
+
unfenced_markdown_lines,
|
|
28
|
+
unfenced_markdown_text,
|
|
29
|
+
)
|
|
22
30
|
from tools.workflow_cli.output import EXIT_GATE_FAIL
|
|
31
|
+
from tools.workflow_cli.stage_schema import PLAN_TASK_FIELD_RE, PLAN_TASK_FIELDS
|
|
23
32
|
|
|
24
33
|
# ---------------------------------------------------------------------------
|
|
25
34
|
# GateResult
|
|
@@ -82,11 +91,42 @@ def check_entry_gate(
|
|
|
82
91
|
# Upstream ID reference pattern
|
|
83
92
|
# ---------------------------------------------------------------------------
|
|
84
93
|
|
|
94
|
+
_FILL_IN_PLACEHOLDER_RE = re.compile(r"<!--\s*fill in\s*-->", re.IGNORECASE)
|
|
95
|
+
|
|
96
|
+
_PLACEHOLDER_PATTERNS = [
|
|
97
|
+
_FILL_IN_PLACEHOLDER_RE, # untouched template body
|
|
98
|
+
re.compile(
|
|
99
|
+
r"(?im)^\s*(?:[-*]\s*)?(?:[A-Za-z][A-Za-z0-9 /_-]*:\s*)?TBD\s*$"
|
|
100
|
+
), # TBD as a line, field value, or list item
|
|
101
|
+
re.compile(r"(?im)^\s*(?:[-*]\s*)?maybe\s*$"),
|
|
102
|
+
re.compile(r"\bTODO later\b", re.IGNORECASE),
|
|
103
|
+
re.compile(
|
|
104
|
+
r"(?im)^\s*(?:[-*]\s*)?(?:[A-Za-z][A-Za-z0-9 /_-]*:\s*)?FIXME\s*$"
|
|
105
|
+
), # FIXME as a placeholder line/field, not prose
|
|
106
|
+
]
|
|
107
|
+
|
|
85
108
|
# IDs that represent upstream references: REQ-*, RISK-*, DES-*, SPEC-*
|
|
86
109
|
_UPSTREAM_ID_PATTERN = re.compile(
|
|
87
110
|
r"\b(REQ-[A-Z]+-\d+|RISK-[A-Z]+-\d+|DES-[A-Z]+-\d+|SPEC-[A-Z]+-\d+)\b"
|
|
88
111
|
)
|
|
89
112
|
|
|
113
|
+
# Trace-ID validation: well-formed vs candidate patterns
|
|
114
|
+
_VALID_TRACE_ID_RE = re.compile(
|
|
115
|
+
r"^(?:REQ|RISK|DES|SPEC)-[A-Z]+-\d+$|^SCOPE-(?:IN|OUT)-\d+$|^PLAN-TASK-\d+$"
|
|
116
|
+
)
|
|
117
|
+
_TRACE_ID_CANDIDATE_RE = re.compile(
|
|
118
|
+
r"\b(?:REQ|RISK|DES|SPEC)-[A-Za-z][A-Za-z0-9_-]*\d[A-Za-z0-9_-]*"
|
|
119
|
+
r"|\bSCOPE-(?:IN|OUT)-[A-Za-z0-9_-]*\d[A-Za-z0-9_-]*"
|
|
120
|
+
r"|\bPLAN-TASK-[A-Za-z0-9_-]*\d[A-Za-z0-9_-]*"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Native-ID heading patterns: stages that MUST define at least one native ID in a heading
|
|
124
|
+
_STAGE_NATIVE_HEADING_PATTERNS = {
|
|
125
|
+
Stage.RISK_DISCOVERY: re.compile(r"(?m)^#+\s+.*\bRISK-[A-Z]+-\d+\b"),
|
|
126
|
+
Stage.DESIGN: re.compile(r"(?m)^#+\s+.*\bDES-[A-Z]+-\d+\b"),
|
|
127
|
+
Stage.SPEC: re.compile(r"(?m)^#+\s+.*\bSPEC-[A-Z]+-\d+\b"),
|
|
128
|
+
}
|
|
129
|
+
|
|
90
130
|
# Closure status tags
|
|
91
131
|
_CLOSURE_TAGS = frozenset(["[ADDRESSED]", "[DEFERRED]", "[N/A]", "[OUT-OF-SCOPE]", "[CLOSED]"])
|
|
92
132
|
|
|
@@ -97,7 +137,7 @@ _DEFINED_ID_PATTERN = re.compile(r"\b([A-Z]+-[A-Z]+-\d+)\b")
|
|
|
97
137
|
def _find_defined_ids(content: str) -> set[str]:
|
|
98
138
|
"""Return IDs that are defined in headings (i.e. the current artifact is defining them)."""
|
|
99
139
|
heading_pattern = re.compile(r"^#{1,6}\s+.*\b([A-Z]+-[A-Z]+-\d+)\b", re.MULTILINE)
|
|
100
|
-
return set(heading_pattern.findall(content))
|
|
140
|
+
return set(heading_pattern.findall(unfenced_markdown_text(content)))
|
|
101
141
|
|
|
102
142
|
|
|
103
143
|
def _find_ids_without_closure(content: str) -> list[str]:
|
|
@@ -106,7 +146,8 @@ def _find_ids_without_closure(content: str) -> list[str]:
|
|
|
106
146
|
IDs defined in headings of the current artifact are excluded — they are
|
|
107
147
|
being *defined* here, not referencing upstream artifacts that need closure.
|
|
108
148
|
"""
|
|
109
|
-
|
|
149
|
+
search_content = unfenced_markdown_text(content)
|
|
150
|
+
all_refs = set(_UPSTREAM_ID_PATTERN.findall(search_content))
|
|
110
151
|
defined_here = _find_defined_ids(content)
|
|
111
152
|
# Only check IDs that are referenced but NOT defined in this artifact
|
|
112
153
|
refs_to_check = all_refs - defined_here
|
|
@@ -121,7 +162,7 @@ def _find_ids_without_closure(content: str) -> list[str]:
|
|
|
121
162
|
re.escape(ref_id) + r"[^\n]*" + re.escape(tag),
|
|
122
163
|
re.IGNORECASE,
|
|
123
164
|
)
|
|
124
|
-
if pattern.search(
|
|
165
|
+
if pattern.search(search_content):
|
|
125
166
|
has_closure = True
|
|
126
167
|
break
|
|
127
168
|
if not has_closure:
|
|
@@ -132,7 +173,7 @@ def _find_ids_without_closure(content: str) -> list[str]:
|
|
|
132
173
|
def _find_duplicate_ids(content: str) -> list[str]:
|
|
133
174
|
"""Return IDs that appear more than once in heading (definition) context."""
|
|
134
175
|
heading_pattern = re.compile(r"^#{1,6}\s+.*\b([A-Z]+-[A-Z]+-\d+)\b", re.MULTILINE)
|
|
135
|
-
heading_ids = heading_pattern.findall(content)
|
|
176
|
+
heading_ids = heading_pattern.findall(unfenced_markdown_text(content))
|
|
136
177
|
|
|
137
178
|
from collections import Counter
|
|
138
179
|
counts = Counter(heading_ids)
|
|
@@ -145,41 +186,9 @@ def _find_duplicate_ids(content: str) -> list[str]:
|
|
|
145
186
|
|
|
146
187
|
_EXTERNAL_DOCS_RE = re.compile(r"^## External Documentation Checked\s*$", re.MULTILINE)
|
|
147
188
|
_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
189
|
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
151
190
|
|
|
152
191
|
|
|
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
192
|
def _plain_table_cell(cell: str) -> str:
|
|
184
193
|
return cell.strip().strip("_*`").strip()
|
|
185
194
|
|
|
@@ -218,14 +227,14 @@ def _is_external_docs_inventory_row(line: str) -> bool:
|
|
|
218
227
|
|
|
219
228
|
def _has_external_docs_inventory(content: str) -> bool:
|
|
220
229
|
section_start = None
|
|
221
|
-
for line, _, end in
|
|
230
|
+
for line, _, end in unfenced_markdown_lines(content):
|
|
222
231
|
if _EXTERNAL_DOCS_RE.match(line):
|
|
223
232
|
section_start = end
|
|
224
233
|
break
|
|
225
234
|
if section_start is None:
|
|
226
235
|
return False
|
|
227
236
|
|
|
228
|
-
for line, start, _ in
|
|
237
|
+
for line, start, _ in unfenced_markdown_lines(content):
|
|
229
238
|
if start < section_start:
|
|
230
239
|
continue
|
|
231
240
|
if _H2_RE.match(line):
|
|
@@ -242,15 +251,14 @@ def _has_external_docs_inventory(content: str) -> bool:
|
|
|
242
251
|
|
|
243
252
|
_PLAN_TASK_RE = re.compile(r"^### PLAN-TASK-\d+", re.MULTILINE)
|
|
244
253
|
_CODE_FENCE_LINE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(.*)$")
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
)
|
|
254
|
+
_INLINE_CODE_VALUE_RE = re.compile(r"^(`+)(.*?)\1$", re.DOTALL)
|
|
255
|
+
_MARKDOWN_LINK_VALUE_RE = re.compile(r"^\[([^\]]+)\]\([^)]+\)$")
|
|
248
256
|
|
|
249
257
|
|
|
250
258
|
def _plan_task_starts(content: str) -> list[int]:
|
|
251
259
|
return [
|
|
252
260
|
start
|
|
253
|
-
for line, start, _ in
|
|
261
|
+
for line, start, _ in unfenced_markdown_lines(content)
|
|
254
262
|
if _PLAN_TASK_RE.match(line)
|
|
255
263
|
]
|
|
256
264
|
|
|
@@ -268,7 +276,7 @@ def _plan_task_field_body(task_body: str, field: str) -> str:
|
|
|
268
276
|
|
|
269
277
|
def _find_plan_task_field(task_body: str, field: str):
|
|
270
278
|
field_re = re.compile(rf"^{re.escape(field)}:[ \t]*(.*)$")
|
|
271
|
-
for line, start, _ in
|
|
279
|
+
for line, start, _ in unfenced_markdown_lines(task_body):
|
|
272
280
|
match = field_re.match(line)
|
|
273
281
|
if match:
|
|
274
282
|
return match, start
|
|
@@ -276,8 +284,8 @@ def _find_plan_task_field(task_body: str, field: str):
|
|
|
276
284
|
|
|
277
285
|
|
|
278
286
|
def _find_next_plan_task_field_start(task_body: str, after: int) -> int | None:
|
|
279
|
-
for line, start, _ in
|
|
280
|
-
if start >= after and
|
|
287
|
+
for line, start, _ in unfenced_markdown_lines(task_body):
|
|
288
|
+
if start >= after and PLAN_TASK_FIELD_RE.match(line):
|
|
281
289
|
return start
|
|
282
290
|
return None
|
|
283
291
|
|
|
@@ -290,6 +298,244 @@ def _plan_task_field_value(task_body: str, field: str) -> str:
|
|
|
290
298
|
return match.group(1).strip()
|
|
291
299
|
|
|
292
300
|
|
|
301
|
+
def _iter_plan_task_bodies(content: str):
|
|
302
|
+
return heading_bounded_bodies(content, _PLAN_TASK_RE.match)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _plan_task_label(body: str) -> str:
|
|
306
|
+
m = re.match(r"###\s+PLAN-TASK-(\d+)", body)
|
|
307
|
+
return f"PLAN-TASK-{m.group(1)}" if m else "PLAN-TASK-?"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _strip_markdown_path_wrappers(value: str) -> str:
|
|
311
|
+
text = value.strip()
|
|
312
|
+
changed = True
|
|
313
|
+
while changed:
|
|
314
|
+
changed = False
|
|
315
|
+
inline_code = _INLINE_CODE_VALUE_RE.fullmatch(text)
|
|
316
|
+
if inline_code:
|
|
317
|
+
text = inline_code.group(2).strip()
|
|
318
|
+
changed = True
|
|
319
|
+
continue
|
|
320
|
+
markdown_link = _MARKDOWN_LINK_VALUE_RE.fullmatch(text)
|
|
321
|
+
if markdown_link:
|
|
322
|
+
text = markdown_link.group(1).strip()
|
|
323
|
+
changed = True
|
|
324
|
+
continue
|
|
325
|
+
if len(text) >= 2 and text.startswith("<") and text.endswith(">"):
|
|
326
|
+
text = text[1:-1].strip()
|
|
327
|
+
changed = True
|
|
328
|
+
continue
|
|
329
|
+
for marker in ("**", "__"):
|
|
330
|
+
if (
|
|
331
|
+
len(text) > 2 * len(marker)
|
|
332
|
+
and text.startswith(marker)
|
|
333
|
+
and text.endswith(marker)
|
|
334
|
+
):
|
|
335
|
+
text = text[len(marker):-len(marker)].strip()
|
|
336
|
+
changed = True
|
|
337
|
+
break
|
|
338
|
+
return text
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _plan_task_path_part(raw_path: str) -> str:
|
|
342
|
+
value = _strip_markdown_path_wrappers(raw_path)
|
|
343
|
+
return _strip_markdown_path_wrappers(value.split("::")[0])
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
_NO_FILE_SENTINELS = frozenset({"n/a"})
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _is_no_file_sentinel(path_part: str) -> bool:
|
|
350
|
+
return path_part.strip().lower() in _NO_FILE_SENTINELS
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _plan_task_file_paths(files_field: str) -> list[str]:
|
|
354
|
+
paths: list[str] = []
|
|
355
|
+
lines = [line for line, _, _ in unfenced_markdown_lines(files_field)]
|
|
356
|
+
if not lines:
|
|
357
|
+
return paths
|
|
358
|
+
|
|
359
|
+
first = lines[0].strip()
|
|
360
|
+
if first:
|
|
361
|
+
raw_path = first[2:].strip() if first.startswith(("- ", "* ")) else first
|
|
362
|
+
path_part = _plan_task_path_part(raw_path)
|
|
363
|
+
if path_part and not _is_no_file_sentinel(path_part):
|
|
364
|
+
paths.append(path_part)
|
|
365
|
+
|
|
366
|
+
for line in lines[1:]:
|
|
367
|
+
stripped = line.strip()
|
|
368
|
+
if not stripped.startswith(("- ", "* ")):
|
|
369
|
+
continue
|
|
370
|
+
path_part = _plan_task_path_part(stripped[2:])
|
|
371
|
+
if path_part and not _is_no_file_sentinel(path_part):
|
|
372
|
+
paths.append(path_part)
|
|
373
|
+
return paths
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _context_pack_repo_root(run_dir: Path) -> Path | None:
|
|
377
|
+
"""Usable Context Pack repo_root, or None when the pack is missing,
|
|
378
|
+
unreadable, invalid JSON, lacks repo_root, or does not point at an existing directory."""
|
|
379
|
+
import json
|
|
380
|
+
pack_json = run_dir / "02-project-context.json"
|
|
381
|
+
if not pack_json.exists():
|
|
382
|
+
return None
|
|
383
|
+
try:
|
|
384
|
+
decoded = json.loads(pack_json.read_text(encoding="utf-8"))
|
|
385
|
+
except (ValueError, OSError):
|
|
386
|
+
return None
|
|
387
|
+
if not isinstance(decoded, dict):
|
|
388
|
+
return None
|
|
389
|
+
raw = decoded.get("repo_root", "")
|
|
390
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
391
|
+
return None
|
|
392
|
+
repo_root = Path(raw)
|
|
393
|
+
if not repo_root.is_dir():
|
|
394
|
+
return None
|
|
395
|
+
return repo_root.resolve()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _python_executable() -> str:
|
|
399
|
+
import shutil
|
|
400
|
+
import sys
|
|
401
|
+
if sys.executable:
|
|
402
|
+
return sys.executable
|
|
403
|
+
return "python3" if shutil.which("python3") else "python"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _context_pack_remediation_command(run_dir: Path) -> str:
|
|
407
|
+
import shlex
|
|
408
|
+
package_root = Path(__file__).resolve().parents[2]
|
|
409
|
+
pythonpath = f"PYTHONPATH={shlex.quote(str(package_root))}${{PYTHONPATH:+:$PYTHONPATH}}"
|
|
410
|
+
command = (
|
|
411
|
+
f"{pythonpath} {shlex.quote(_python_executable())} -m tools.workflow_cli "
|
|
412
|
+
f"context-build --work-id {run_dir.name}"
|
|
413
|
+
)
|
|
414
|
+
if run_dir.parent.name == ".req-to-plan":
|
|
415
|
+
base_dir = run_dir.parent.parent
|
|
416
|
+
try:
|
|
417
|
+
command += f" --base-path {shlex.quote(str(base_dir.resolve()))}"
|
|
418
|
+
except (OSError, RuntimeError):
|
|
419
|
+
# Path resolution failed (for example, a symlink loop).
|
|
420
|
+
command += " --base-path <base-dir>"
|
|
421
|
+
return command + " --repo-path <repo-dir>"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _check_plan_context_pack(run_dir: Path) -> list[str]:
|
|
425
|
+
"""R11: standard-tier PLAN must anchor file facts to a usable Context Pack.
|
|
426
|
+
|
|
427
|
+
Every no-usable-truth-anchor path blocks loudly; after the user replaces
|
|
428
|
+
<repo-dir>, the remediation command can import the installed workflow modules.
|
|
429
|
+
"""
|
|
430
|
+
if _context_pack_repo_root(run_dir) is not None:
|
|
431
|
+
return []
|
|
432
|
+
return [
|
|
433
|
+
"Standard-tier PLAN requires a usable Project Context Pack: "
|
|
434
|
+
"02-project-context.json is missing, unreadable, invalid, or its "
|
|
435
|
+
"repo_root is unavailable. Build it with: "
|
|
436
|
+
f"{_context_pack_remediation_command(run_dir)}"
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _check_plan_file_refs(run_dir: Path, content: str) -> list[str]:
|
|
441
|
+
"""Hard-check Files paths against the Context Pack repo_root. create-type tasks
|
|
442
|
+
are exempt; the part after '::' (a symbol) is advisory and not checked (no AST pack yet)."""
|
|
443
|
+
repo_root = _context_pack_repo_root(run_dir)
|
|
444
|
+
if repo_root is None:
|
|
445
|
+
return [] # no usable ground truth; standard tier blocks via _check_plan_context_pack
|
|
446
|
+
issues: list[str] = []
|
|
447
|
+
for body in _iter_plan_task_bodies(content):
|
|
448
|
+
skip_missing_path = _normalized_change_type(_task_change_type(body)) == "create"
|
|
449
|
+
files_field = _plan_task_field_body(body, "Files")
|
|
450
|
+
for path_part in _plan_task_file_paths(files_field):
|
|
451
|
+
path = Path(path_part)
|
|
452
|
+
if path.is_absolute():
|
|
453
|
+
resolved = path.resolve()
|
|
454
|
+
else:
|
|
455
|
+
resolved = (repo_root / path).resolve()
|
|
456
|
+
try:
|
|
457
|
+
resolved.relative_to(repo_root)
|
|
458
|
+
except ValueError:
|
|
459
|
+
issues.append(
|
|
460
|
+
f"PLAN-TASK Files references path outside repo_root {path_part!r}."
|
|
461
|
+
)
|
|
462
|
+
continue
|
|
463
|
+
if not resolved.exists():
|
|
464
|
+
if not skip_missing_path:
|
|
465
|
+
issues.append(
|
|
466
|
+
f"PLAN-TASK Files references missing path {path_part!r} "
|
|
467
|
+
"(mark the task 'Change Type: create' if it is a new file)."
|
|
468
|
+
)
|
|
469
|
+
return issues
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _check_spec_refs_valid(run_dir: Path, content: str) -> list[str]:
|
|
473
|
+
spec_path = run_dir / STAGE_ARTIFACT_MAP[Stage.SPEC]
|
|
474
|
+
spec_content = (
|
|
475
|
+
strip_readonly_sections(spec_path.read_text(encoding="utf-8"))
|
|
476
|
+
if spec_path.exists()
|
|
477
|
+
else ""
|
|
478
|
+
)
|
|
479
|
+
defined_specs: set[str] = set()
|
|
480
|
+
for line, _, _ in unfenced_markdown_lines(spec_content):
|
|
481
|
+
if line.lstrip().startswith("#"):
|
|
482
|
+
defined_specs.update(re.findall(r"\bSPEC-[A-Z]+-\d+\b", line))
|
|
483
|
+
issues: list[str] = []
|
|
484
|
+
for body in _iter_plan_task_bodies(content):
|
|
485
|
+
refs_body = _plan_task_field_body(body, "Spec References")
|
|
486
|
+
refs = re.findall(r"SPEC-[A-Z]+-\d+", refs_body)
|
|
487
|
+
if refs_body.strip() and not refs:
|
|
488
|
+
issues.append(
|
|
489
|
+
f"{_plan_task_label(body)} must reference at least one SPEC-* ID "
|
|
490
|
+
"in 'Spec References:'."
|
|
491
|
+
)
|
|
492
|
+
for ref in refs:
|
|
493
|
+
if ref not in defined_specs:
|
|
494
|
+
issues.append(f"PLAN-TASK references {ref} which is not defined in the SPEC artifact.")
|
|
495
|
+
return issues
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# R10: Change Type is a closed operation-kind enum; 'new' is a legacy alias.
|
|
499
|
+
_CHANGE_TYPE_VALUES = frozenset({"create", "modify", "delete"})
|
|
500
|
+
_CHANGE_TYPE_ALIASES = {"new": "create"}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _normalized_change_type(raw: str) -> str:
|
|
504
|
+
value = raw.strip().lower()
|
|
505
|
+
return _CHANGE_TYPE_ALIASES.get(value, value)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _task_change_type(body: str) -> str:
|
|
509
|
+
"""Whitespace-normalized Change Type field body (same line + continuation lines)."""
|
|
510
|
+
return " ".join(_plan_task_field_body(body, "Change Type").split())
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _check_plan_task_fields(content: str) -> list[str]:
|
|
514
|
+
issues: list[str] = []
|
|
515
|
+
numbers: list[int] = []
|
|
516
|
+
for body in _iter_plan_task_bodies(content):
|
|
517
|
+
m = re.match(r"###\s+PLAN-TASK-(\d+)", body)
|
|
518
|
+
num = int(m.group(1)) if m else None
|
|
519
|
+
if num is not None:
|
|
520
|
+
numbers.append(num)
|
|
521
|
+
label = _plan_task_label(body)
|
|
522
|
+
for field in PLAN_TASK_FIELDS:
|
|
523
|
+
if not _plan_task_field_body(body, field).strip():
|
|
524
|
+
issues.append(f"{label} is missing a non-empty '{field}:' field.")
|
|
525
|
+
raw_change_type = _task_change_type(body)
|
|
526
|
+
if raw_change_type and _normalized_change_type(raw_change_type) not in _CHANGE_TYPE_VALUES:
|
|
527
|
+
issues.append(
|
|
528
|
+
f"{label} has invalid 'Change Type: {raw_change_type}'; "
|
|
529
|
+
"allowed: create|modify|delete (alias: new = create)."
|
|
530
|
+
)
|
|
531
|
+
if numbers:
|
|
532
|
+
if len(set(numbers)) != len(numbers):
|
|
533
|
+
issues.append("PLAN-TASK numbers must be unique.")
|
|
534
|
+
elif sorted(numbers) != list(range(1, len(numbers) + 1)):
|
|
535
|
+
issues.append("PLAN-TASK numbers must be contiguous starting at 1.")
|
|
536
|
+
return issues
|
|
537
|
+
|
|
538
|
+
|
|
293
539
|
def _has_complete_code_fence(content: str) -> bool:
|
|
294
540
|
fence_char = ""
|
|
295
541
|
fence_len = 0
|
|
@@ -324,12 +570,10 @@ def _has_complete_code_fence(content: str) -> bool:
|
|
|
324
570
|
|
|
325
571
|
def _plan_tasks_missing_code(content: str) -> bool:
|
|
326
572
|
"""True if any TDD-applicable PLAN-TASK has no fenced code block in its Skeleton field."""
|
|
327
|
-
|
|
328
|
-
if not
|
|
573
|
+
bodies = list(_iter_plan_task_bodies(content))
|
|
574
|
+
if not bodies:
|
|
329
575
|
return False
|
|
330
|
-
|
|
331
|
-
for i in range(len(starts)):
|
|
332
|
-
body = content[bounds[i]:bounds[i + 1]]
|
|
576
|
+
for body in bodies:
|
|
333
577
|
skeleton = _plan_task_field_body(body, "Skeleton")
|
|
334
578
|
tdd_applicable = _plan_task_field_value(body, "TDD Applicable")
|
|
335
579
|
if tdd_applicable.lower() == "yes" and not _has_complete_code_fence(skeleton):
|
|
@@ -337,6 +581,312 @@ def _plan_tasks_missing_code(content: str) -> bool:
|
|
|
337
581
|
return False
|
|
338
582
|
|
|
339
583
|
|
|
584
|
+
def _check_plan_task_skeleton_placeholders(content: str) -> list[str]:
|
|
585
|
+
issues: list[str] = []
|
|
586
|
+
for body in _iter_plan_task_bodies(content):
|
|
587
|
+
skeleton = _plan_task_field_body(body, "Skeleton")
|
|
588
|
+
if skeleton.strip() and any(pattern.search(skeleton) for pattern in _PLACEHOLDER_PATTERNS):
|
|
589
|
+
issues.append(
|
|
590
|
+
f"{_plan_task_label(body)} Skeleton contains an unresolved template "
|
|
591
|
+
"placeholder; replace placeholder text before passing the gate."
|
|
592
|
+
)
|
|
593
|
+
return issues
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _section_bodies(content: str, heading: str) -> list[str]:
|
|
597
|
+
"""Return all bodies under `heading`, each stopping at the next same-or-higher heading."""
|
|
598
|
+
level = len(heading) - len(heading.lstrip("#"))
|
|
599
|
+
bodies: list[str] = []
|
|
600
|
+
out: list[str] | None = None
|
|
601
|
+
for line, _, _ in unfenced_markdown_lines(content):
|
|
602
|
+
if line.strip() == heading:
|
|
603
|
+
if out is not None:
|
|
604
|
+
bodies.append("\n".join(out))
|
|
605
|
+
out = []
|
|
606
|
+
continue
|
|
607
|
+
if out is not None:
|
|
608
|
+
stripped = line.lstrip()
|
|
609
|
+
if stripped.startswith("#"):
|
|
610
|
+
# Count hashes of this line's heading
|
|
611
|
+
line_level = len(stripped) - len(stripped.lstrip("#"))
|
|
612
|
+
if line_level <= level:
|
|
613
|
+
bodies.append("\n".join(out))
|
|
614
|
+
out = None
|
|
615
|
+
continue
|
|
616
|
+
out.append(line.rstrip("\r\n"))
|
|
617
|
+
if out is not None:
|
|
618
|
+
bodies.append("\n".join(out))
|
|
619
|
+
return bodies
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _section_body(content: str, heading: str) -> str:
|
|
623
|
+
"""Return the first section body under `heading`, stopping at the next same-or-higher heading."""
|
|
624
|
+
bodies = _section_bodies(content, heading)
|
|
625
|
+
return bodies[0] if bodies else ""
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _section_entries_missing_id(content: str, heading: str, id_prefix: str) -> list[str]:
|
|
629
|
+
missing: list[str] = []
|
|
630
|
+
pattern = re.compile(rf"\b{re.escape(id_prefix)}-\d+\b")
|
|
631
|
+
for line in _section_body(content, heading).splitlines():
|
|
632
|
+
stripped = line.strip()
|
|
633
|
+
if not stripped or stripped.startswith("<!--"):
|
|
634
|
+
continue
|
|
635
|
+
if not stripped.startswith(("- ", "* ")):
|
|
636
|
+
continue
|
|
637
|
+
if not pattern.search(stripped):
|
|
638
|
+
missing.append(stripped)
|
|
639
|
+
return missing
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _section_entry_ids(content: str, heading: str, id_prefix: str) -> list[str]:
|
|
643
|
+
ids: list[str] = []
|
|
644
|
+
pattern = re.compile(rf"\b{re.escape(id_prefix)}-\d+\b")
|
|
645
|
+
for line in _section_body(content, heading).splitlines():
|
|
646
|
+
stripped = line.strip()
|
|
647
|
+
if not stripped or stripped.startswith("<!--"):
|
|
648
|
+
continue
|
|
649
|
+
if not stripped.startswith(("- ", "* ")):
|
|
650
|
+
continue
|
|
651
|
+
match = pattern.search(stripped)
|
|
652
|
+
if match:
|
|
653
|
+
ids.append(match.group(0))
|
|
654
|
+
return ids
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _section_has_bullets(content: str, heading: str) -> bool:
|
|
658
|
+
return any(l.lstrip().startswith(("- ", "* ")) for l in _section_body(content, heading).splitlines())
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _check_elicitation(stage: Stage, tier: TierEstimate, content: str) -> list[str]:
|
|
662
|
+
"""R8: standard-tier brief must record at least one assumption or open question."""
|
|
663
|
+
from tools.workflow_cli.models import TierBase
|
|
664
|
+
if stage != Stage.REQUIREMENT_BRIEF or tier.base != TierBase.STANDARD:
|
|
665
|
+
return []
|
|
666
|
+
if _section_has_bullets(content, "## Assumptions") or _section_has_bullets(content, "## Open Questions"):
|
|
667
|
+
return []
|
|
668
|
+
return ["Standard-tier brief must record at least one assumption or open question (R8 elicitation)."]
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# R12: decision-request lifecycle vocabulary. The gate owns ONLY the Status
|
|
672
|
+
# lifecycle (enum, line presence, section non-emptiness, Selected/Rationale
|
|
673
|
+
# when selected); Question/Options/Recommended are template guidance enforced
|
|
674
|
+
# at checkpoint, not here (Agent/CLI boundary).
|
|
675
|
+
_DECISION_SECTION = "## Decision Requests"
|
|
676
|
+
_DECISION_BLOCK_RE = re.compile(r"^###\s+(DECISION-\d+)\b")
|
|
677
|
+
_DECISION_NESTED_MARKER_RE = re.compile(
|
|
678
|
+
r"^(?:#{1,6}\s+|[-*]\s+(?:\[[ xX]\]\s+)?|(?:\[[ xX]\]\s+)?)DECISION-\d+\b"
|
|
679
|
+
)
|
|
680
|
+
_DECISION_STATUS_VALUES = frozenset({"pending", "selected"})
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _decision_field_value(block_lines: list[str], field: str) -> str | None:
|
|
684
|
+
"""Value of a `Field:` line within a DECISION block; None when absent."""
|
|
685
|
+
field_re = re.compile(rf"^{re.escape(field)}:\s*(.*)$")
|
|
686
|
+
for line in block_lines:
|
|
687
|
+
m = field_re.match(line.strip())
|
|
688
|
+
if m:
|
|
689
|
+
return m.group(1).strip()
|
|
690
|
+
return None
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _decision_field_count(block_lines: list[str], field: str) -> int:
|
|
694
|
+
field_re = re.compile(rf"^{re.escape(field)}:")
|
|
695
|
+
return sum(1 for line in block_lines if field_re.match(line.strip()))
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _check_decision_requests(stage: Stage, tier: TierEstimate, content: str) -> list[str]:
|
|
699
|
+
"""R12: standard DESIGN must list pending human decisions or state `none`."""
|
|
700
|
+
from tools.workflow_cli.models import TierBase
|
|
701
|
+
if stage != Stage.DESIGN or tier.base != TierBase.STANDARD:
|
|
702
|
+
return []
|
|
703
|
+
blocks: list[tuple[str, list[str]]] = []
|
|
704
|
+
stray: list[str] = []
|
|
705
|
+
for section_body in _section_bodies(content, _DECISION_SECTION):
|
|
706
|
+
lines = section_body.splitlines()
|
|
707
|
+
starts = [
|
|
708
|
+
(i, m)
|
|
709
|
+
for i, line in enumerate(lines)
|
|
710
|
+
if (m := _DECISION_BLOCK_RE.match(line.strip()))
|
|
711
|
+
]
|
|
712
|
+
covered: set[int] = set()
|
|
713
|
+
for start, match in starts:
|
|
714
|
+
end = len(lines)
|
|
715
|
+
for j in range(start + 1, len(lines)):
|
|
716
|
+
if heading_level(lines[j]) is not None:
|
|
717
|
+
end = j
|
|
718
|
+
break
|
|
719
|
+
decision_id = match.group(1)
|
|
720
|
+
blocks.append((decision_id, lines[start + 1:end]))
|
|
721
|
+
covered.update(range(start, end))
|
|
722
|
+
stray.extend(
|
|
723
|
+
line.strip()
|
|
724
|
+
for i, line in enumerate(lines)
|
|
725
|
+
if i not in covered and line.strip() and not line.strip().startswith("<!--")
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
issues: list[str] = []
|
|
729
|
+
if not blocks:
|
|
730
|
+
if stray == ["none"]:
|
|
731
|
+
return []
|
|
732
|
+
if not stray:
|
|
733
|
+
issues.append(
|
|
734
|
+
"## Decision Requests is empty; state exactly `none` or list "
|
|
735
|
+
"`### DECISION-NNN` blocks (R12)."
|
|
736
|
+
)
|
|
737
|
+
else:
|
|
738
|
+
issues.append(
|
|
739
|
+
"## Decision Requests must be exactly `none` (sole non-comment "
|
|
740
|
+
"content) or `### DECISION-NNN` blocks (R12)."
|
|
741
|
+
)
|
|
742
|
+
return issues
|
|
743
|
+
if stray:
|
|
744
|
+
if "none" in stray:
|
|
745
|
+
issues.append(
|
|
746
|
+
"## Decision Requests mixes `none` with DECISION blocks; keep one (R12)."
|
|
747
|
+
)
|
|
748
|
+
else:
|
|
749
|
+
issues.append(
|
|
750
|
+
"## Decision Requests contains non-comment prose outside DECISION blocks; "
|
|
751
|
+
"use exactly `none` or `### DECISION-NNN` blocks (R12)."
|
|
752
|
+
)
|
|
753
|
+
for dup_id, count in Counter(decision_id for decision_id, _ in blocks).items():
|
|
754
|
+
if count > 1:
|
|
755
|
+
issues.append(
|
|
756
|
+
f"Duplicate decision id {dup_id}; each DECISION-NNN must be unique (R12)."
|
|
757
|
+
)
|
|
758
|
+
for decision_id, body in blocks:
|
|
759
|
+
for line in body:
|
|
760
|
+
if _DECISION_NESTED_MARKER_RE.match(line.strip()):
|
|
761
|
+
issues.append(
|
|
762
|
+
f"{decision_id} body contains a nested 'DECISION-NNN' marker; "
|
|
763
|
+
"each decision must be its own '### DECISION-NNN' block (R12)."
|
|
764
|
+
)
|
|
765
|
+
break
|
|
766
|
+
if _decision_field_count(body, "Status") > 1:
|
|
767
|
+
issues.append(
|
|
768
|
+
f"{decision_id} has multiple 'Status:' lines; keep exactly one (R12)."
|
|
769
|
+
)
|
|
770
|
+
continue
|
|
771
|
+
status = _decision_field_value(body, "Status")
|
|
772
|
+
if status is None:
|
|
773
|
+
issues.append(
|
|
774
|
+
f"{decision_id} is missing a 'Status:' line; allowed: pending|selected (R12)."
|
|
775
|
+
)
|
|
776
|
+
continue
|
|
777
|
+
if status.lower() not in _DECISION_STATUS_VALUES:
|
|
778
|
+
issues.append(
|
|
779
|
+
f"{decision_id} has invalid 'Status: {status}'; allowed: pending|selected (R12)."
|
|
780
|
+
)
|
|
781
|
+
continue
|
|
782
|
+
if status.lower() == "pending":
|
|
783
|
+
issues.append(
|
|
784
|
+
f"Unresolved decision request {decision_id} (Status: pending); "
|
|
785
|
+
"a human must choose before this gate can pass (R12)."
|
|
786
|
+
)
|
|
787
|
+
continue
|
|
788
|
+
for field in ("Selected", "Rationale"):
|
|
789
|
+
if not (_decision_field_value(body, field) or "").strip():
|
|
790
|
+
issues.append(
|
|
791
|
+
f"{decision_id} is 'Status: selected' but missing a non-empty "
|
|
792
|
+
f"'{field}:' line (R12)."
|
|
793
|
+
)
|
|
794
|
+
return issues
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _check_scope_freeze(stage: Stage, content: str) -> list[str]:
|
|
798
|
+
"""R8: brief's In/Out-of-Scope must carry stable IDs so trace can anchor them."""
|
|
799
|
+
if stage != Stage.REQUIREMENT_BRIEF:
|
|
800
|
+
return []
|
|
801
|
+
issues: list[str] = []
|
|
802
|
+
for entry in _section_entries_missing_id(content, "## In-Scope", "SCOPE-IN"):
|
|
803
|
+
issues.append(f"In-Scope entry must carry a SCOPE-IN-* stable ID (R8): {entry}")
|
|
804
|
+
for entry in _section_entries_missing_id(content, "## Out-of-Scope", "SCOPE-OUT"):
|
|
805
|
+
issues.append(f"Out-of-Scope entry must carry a SCOPE-OUT-* stable ID (R8): {entry}")
|
|
806
|
+
for id_, count in Counter(_section_entry_ids(content, "## In-Scope", "SCOPE-IN")).items():
|
|
807
|
+
if count > 1:
|
|
808
|
+
issues.append(f"In-Scope stable ID {id_} is duplicate; scope IDs must be unique (R8).")
|
|
809
|
+
for id_, count in Counter(_section_entry_ids(content, "## Out-of-Scope", "SCOPE-OUT")).items():
|
|
810
|
+
if count > 1:
|
|
811
|
+
issues.append(f"Out-of-Scope stable ID {id_} is duplicate; scope IDs must be unique (R8).")
|
|
812
|
+
if not re.search(r"\bSCOPE-IN-\d+\b", _section_body(content, "## In-Scope")):
|
|
813
|
+
issues.append("In-Scope must list at least one stable-ID entry (SCOPE-IN-001, ...); none found (R8).")
|
|
814
|
+
if not re.search(r"\bSCOPE-OUT-\d+\b", _section_body(content, "## Out-of-Scope")):
|
|
815
|
+
issues.append("Out-of-Scope must list at least one stable-ID entry (SCOPE-OUT-001, ...); none found (R8).")
|
|
816
|
+
return issues
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _has_meaningful_body(text: str) -> bool:
|
|
820
|
+
"""True if `text` has at least one non-empty, non-comment line."""
|
|
821
|
+
for line in text.splitlines():
|
|
822
|
+
stripped = line.strip()
|
|
823
|
+
if stripped and not stripped.startswith("<!--"):
|
|
824
|
+
return True
|
|
825
|
+
return False
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _check_stage_schema(stage: Stage, tier: TierEstimate, content: str) -> list[str]:
|
|
829
|
+
"""R2 schema gate: required headings present, each required section has a
|
|
830
|
+
non-placeholder body, trace IDs are well-formed, RISK/DESIGN/SPEC define a
|
|
831
|
+
native ID heading, and no unresolved placeholders remain."""
|
|
832
|
+
from tools.workflow_cli.stage_schema import required_headings
|
|
833
|
+
issues: list[str] = []
|
|
834
|
+
headings = required_headings(stage, tier.base)
|
|
835
|
+
native = _STAGE_NATIVE_HEADING_PATTERNS.get(stage)
|
|
836
|
+
unfenced_content = unfenced_markdown_text(content)
|
|
837
|
+
present_headings = {
|
|
838
|
+
line.strip()
|
|
839
|
+
for line, _, _ in unfenced_markdown_lines(content)
|
|
840
|
+
if line.lstrip().startswith("#")
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if not headings and native is None:
|
|
844
|
+
return issues
|
|
845
|
+
|
|
846
|
+
# R2.1: required headings must be present
|
|
847
|
+
for heading in headings:
|
|
848
|
+
if heading not in present_headings:
|
|
849
|
+
issues.append(
|
|
850
|
+
f"Missing required section {heading!r} for stage {stage.value!r} "
|
|
851
|
+
f"at tier '{tier.base.value}'."
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
# R2.3a: each required heading's section must have a non-placeholder body
|
|
855
|
+
for heading in headings:
|
|
856
|
+
if heading not in present_headings:
|
|
857
|
+
continue # already reported by the required-heading presence check
|
|
858
|
+
body = _section_body(content, heading)
|
|
859
|
+
if not _has_meaningful_body(body):
|
|
860
|
+
issues.append(
|
|
861
|
+
f"Required section {heading!r} must contain non-placeholder body content."
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
# R2.3b: any trace-ID-looking token must be well-formed
|
|
865
|
+
for token in _TRACE_ID_CANDIDATE_RE.findall(unfenced_content):
|
|
866
|
+
if not _VALID_TRACE_ID_RE.fullmatch(token):
|
|
867
|
+
issues.append(
|
|
868
|
+
f"Malformed trace ID {token!r}; use REQ-AREA-001, SPEC-AREA-001, "
|
|
869
|
+
"SCOPE-IN-001, or PLAN-TASK-001 style IDs."
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
# R2.3c: RISK_DISCOVERY / DESIGN / SPEC must define at least one native trace ID in a heading
|
|
873
|
+
if native is not None and not native.search(unfenced_content):
|
|
874
|
+
issues.append(
|
|
875
|
+
f"Stage {stage.value!r} must define at least one native trace ID in a heading "
|
|
876
|
+
f"matching {native.pattern!r}."
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
# R2.2: placeholder scan
|
|
880
|
+
for pat in _PLACEHOLDER_PATTERNS:
|
|
881
|
+
if pat.search(unfenced_content):
|
|
882
|
+
issues.append(
|
|
883
|
+
"Artifact contains an unresolved placeholder "
|
|
884
|
+
f"(pattern {pat.pattern!r}); fill it before passing the gate."
|
|
885
|
+
)
|
|
886
|
+
break
|
|
887
|
+
return issues
|
|
888
|
+
|
|
889
|
+
|
|
340
890
|
# ---------------------------------------------------------------------------
|
|
341
891
|
# Quality Gate
|
|
342
892
|
# ---------------------------------------------------------------------------
|
|
@@ -358,14 +908,22 @@ def check_quality_gate(
|
|
|
358
908
|
)
|
|
359
909
|
|
|
360
910
|
issues: list[str] = []
|
|
911
|
+
gate_content = strip_readonly_sections(artifact_content)
|
|
361
912
|
|
|
362
913
|
# Check 2: content must be non-empty
|
|
363
|
-
if not
|
|
914
|
+
if not gate_content or not gate_content.strip():
|
|
364
915
|
issues.append("Artifact content is empty or whitespace-only.")
|
|
365
916
|
|
|
366
917
|
if not issues:
|
|
367
918
|
# Check 3: upstream reference coverage closure (all tiers)
|
|
368
|
-
unclosed = _find_ids_without_closure(
|
|
919
|
+
unclosed = _find_ids_without_closure(gate_content)
|
|
920
|
+
if stage == Stage.PLAN:
|
|
921
|
+
from tools.workflow_cli.trace import plan_consumed_spec_ids
|
|
922
|
+
consumed_specs = plan_consumed_spec_ids(run_dir)
|
|
923
|
+
unclosed = [
|
|
924
|
+
ref_id for ref_id in unclosed
|
|
925
|
+
if not (ref_id.startswith("SPEC-") and ref_id in consumed_specs)
|
|
926
|
+
]
|
|
369
927
|
for ref_id in unclosed:
|
|
370
928
|
issues.append(
|
|
371
929
|
f"Upstream reference {ref_id!r} appears in artifact but has no closure status tag "
|
|
@@ -373,35 +931,66 @@ def check_quality_gate(
|
|
|
373
931
|
)
|
|
374
932
|
|
|
375
933
|
# Check 4: ID uniqueness within artifact
|
|
376
|
-
duplicates = _find_duplicate_ids(
|
|
934
|
+
duplicates = _find_duplicate_ids(gate_content)
|
|
377
935
|
for dup_id in duplicates:
|
|
378
936
|
issues.append(
|
|
379
937
|
f"Duplicate ID definition {dup_id!r} found in artifact; each ID must be unique."
|
|
380
938
|
)
|
|
381
939
|
|
|
382
|
-
# Check 5 (PLAN, standard tier):
|
|
940
|
+
# Check 5 (PLAN, standard tier): usable Context Pack required (R11);
|
|
941
|
+
# TDD-applicable tasks must carry a code block.
|
|
383
942
|
from tools.workflow_cli.models import TierBase
|
|
384
943
|
if stage == Stage.PLAN and tier.base == TierBase.STANDARD:
|
|
385
|
-
|
|
944
|
+
# R11: a usable Context Pack is the truth anchor for file-ref checks.
|
|
945
|
+
issues.extend(_check_plan_context_pack(run_dir))
|
|
946
|
+
if not _plan_task_starts(gate_content):
|
|
386
947
|
issues.append(
|
|
387
948
|
"PLAN is missing '### PLAN-TASK-*' sections; standard tier requires "
|
|
388
949
|
"machine-parseable executable anchors."
|
|
389
950
|
)
|
|
390
|
-
elif _plan_tasks_missing_code(
|
|
951
|
+
elif _plan_tasks_missing_code(gate_content):
|
|
391
952
|
issues.append(
|
|
392
953
|
"PLAN has a 'TDD Applicable: yes' task with no fenced code block; "
|
|
393
954
|
"add a Skeleton code block (standard tier requires executable anchors)."
|
|
394
955
|
)
|
|
395
956
|
|
|
957
|
+
# Check 5b (PLAN): trace closure — all upstream SPEC/RISK/SCOPE-IN IDs must be consumed.
|
|
958
|
+
if stage == Stage.PLAN:
|
|
959
|
+
from tools.workflow_cli.trace import check_trace_closure, scope_out_violations
|
|
960
|
+
issues.extend(check_trace_closure(run_dir))
|
|
961
|
+
for sid in scope_out_violations(run_dir):
|
|
962
|
+
issues.append(f"PLAN references out-of-scope item {sid}; scope overflow (R8).")
|
|
963
|
+
# R5.1: required fields + contiguous numbering
|
|
964
|
+
issues.extend(_check_plan_task_fields(gate_content))
|
|
965
|
+
# R5.2: dangling SPEC references
|
|
966
|
+
issues.extend(_check_spec_refs_valid(run_dir, gate_content))
|
|
967
|
+
# R5.2b: Skeleton is fenced, so detect template placeholders there explicitly.
|
|
968
|
+
issues.extend(_check_plan_task_skeleton_placeholders(gate_content))
|
|
969
|
+
# R5.3: file refs vs Context Pack repo_root
|
|
970
|
+
issues.extend(_check_plan_file_refs(run_dir, gate_content))
|
|
971
|
+
|
|
396
972
|
# Check 6 (SPEC): the External Documentation Checked section must be present and non-empty.
|
|
397
973
|
if stage == Stage.SPEC:
|
|
398
|
-
if not _has_external_docs_inventory(
|
|
974
|
+
if not _has_external_docs_inventory(gate_content):
|
|
399
975
|
issues.append(
|
|
400
976
|
"SPEC is missing a non-empty '## External Documentation Checked' section. "
|
|
401
977
|
"Add it; if there are no external dependencies, include an explicit "
|
|
402
978
|
"'N/A — no external dependencies' row."
|
|
403
979
|
)
|
|
404
980
|
|
|
981
|
+
# Check 7 (R2): tier-aware required-section schema.
|
|
982
|
+
if not issues:
|
|
983
|
+
issues.extend(_check_stage_schema(stage, tier, gate_content))
|
|
984
|
+
|
|
985
|
+
# Check 8 (R8): scope-freeze — In/Out-of-Scope entries must carry stable IDs.
|
|
986
|
+
issues.extend(_check_scope_freeze(stage, gate_content))
|
|
987
|
+
|
|
988
|
+
# Check 9 (R8): elicitation — standard-tier brief must record at least one assumption or open question.
|
|
989
|
+
issues.extend(_check_elicitation(stage, tier, gate_content))
|
|
990
|
+
|
|
991
|
+
# Check 10 (R12): standard DESIGN must resolve decision requests.
|
|
992
|
+
issues.extend(_check_decision_requests(stage, tier, gate_content))
|
|
993
|
+
|
|
405
994
|
return GateResult(
|
|
406
995
|
passed=len(issues) == 0,
|
|
407
996
|
issues=issues,
|