@xenonbyte/req-2-plan 0.2.3 → 0.3.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/package.json +3 -3
- package/tools/workflow_cli/agent_shortcuts.py +57 -6
- 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 +416 -56
- 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 +55 -0
- package/tools/workflow_cli/trace.py +224 -0
- package/tools/workflow_cli/version.py +1 -1
- package/docs/req-to-plan-design.md +0 -277
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Shared Markdown helpers used by gate and trace checks.
|
|
2
|
+
|
|
3
|
+
The single source of truth for "read only the text outside fenced code
|
|
4
|
+
blocks", so template/example snippets inside ``` ... ``` (or ~~~) fences
|
|
5
|
+
do not register as real headings, trace IDs, or references.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
# A fence opener/closer: up to 3 leading spaces, then 3+ backticks or tildes.
|
|
12
|
+
_FENCE_MARKER_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})")
|
|
13
|
+
_READONLY_SECTION_RE = re.compile(
|
|
14
|
+
r"^[ \t]{0,3}(#{1,6})\s+"
|
|
15
|
+
r"(?:Upstream Summary|Project Context)\s+\(read-only\)\s*(?:#+\s*)?$",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
_READONLY_SECTION_END_RE = re.compile(
|
|
19
|
+
r"^[ \t]{0,3}<!--\s*/r2p-read-only\s*-->\s*$",
|
|
20
|
+
re.IGNORECASE,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def unfenced_markdown_lines(content: str):
|
|
25
|
+
"""Yield (line, start, end) for lines outside Markdown fenced code blocks.
|
|
26
|
+
|
|
27
|
+
`start`/`end` are byte-less character offsets into the original `content`,
|
|
28
|
+
so callers can map a yielded line back to its position in the source.
|
|
29
|
+
"""
|
|
30
|
+
fence_char = ""
|
|
31
|
+
fence_len = 0
|
|
32
|
+
offset = 0
|
|
33
|
+
for line in content.splitlines(keepends=True):
|
|
34
|
+
marker = _FENCE_MARKER_RE.match(line)
|
|
35
|
+
if fence_char:
|
|
36
|
+
if (
|
|
37
|
+
marker
|
|
38
|
+
and marker.group(1)[0] == fence_char
|
|
39
|
+
and len(marker.group(1)) >= fence_len
|
|
40
|
+
and not line[marker.end():].strip()
|
|
41
|
+
):
|
|
42
|
+
fence_char = ""
|
|
43
|
+
fence_len = 0
|
|
44
|
+
offset += len(line)
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
if marker:
|
|
48
|
+
fence_char = marker.group(1)[0]
|
|
49
|
+
fence_len = len(marker.group(1))
|
|
50
|
+
offset += len(line)
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
start = offset
|
|
54
|
+
offset += len(line)
|
|
55
|
+
yield line, start, offset
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def unfenced_markdown_text(content: str) -> str:
|
|
59
|
+
return "".join(line for line, _, _ in unfenced_markdown_lines(content))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def strip_readonly_sections(content: str) -> str:
|
|
63
|
+
"""Remove seeded read-only Markdown sections before structural validation."""
|
|
64
|
+
lines = list(unfenced_markdown_lines(content))
|
|
65
|
+
headings = [
|
|
66
|
+
(start, level)
|
|
67
|
+
for line, start, _ in lines
|
|
68
|
+
if (level := heading_level(line)) is not None
|
|
69
|
+
]
|
|
70
|
+
markers = [
|
|
71
|
+
(start, len(match.group(1)))
|
|
72
|
+
for line, start, _ in lines
|
|
73
|
+
if (match := _READONLY_SECTION_RE.match(line))
|
|
74
|
+
]
|
|
75
|
+
removals: list[tuple[int, int]] = []
|
|
76
|
+
for marker_start, marker_level in markers:
|
|
77
|
+
next_marker_start = next(
|
|
78
|
+
(start for start, _ in markers if start > marker_start),
|
|
79
|
+
len(content),
|
|
80
|
+
)
|
|
81
|
+
# Seeded payloads can contain copied documents with their own #/##
|
|
82
|
+
# headings, so prefer the explicit terminator when the seed provides it.
|
|
83
|
+
explicit_end = next(
|
|
84
|
+
(
|
|
85
|
+
line_end
|
|
86
|
+
for line, start, line_end in lines
|
|
87
|
+
if marker_start < start < next_marker_start
|
|
88
|
+
and _READONLY_SECTION_END_RE.match(line)
|
|
89
|
+
),
|
|
90
|
+
None,
|
|
91
|
+
)
|
|
92
|
+
if explicit_end is not None:
|
|
93
|
+
removals.append((marker_start, explicit_end))
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
end = len(content)
|
|
97
|
+
for heading_start, heading_level_ in headings:
|
|
98
|
+
if heading_start <= marker_start:
|
|
99
|
+
continue
|
|
100
|
+
if heading_level_ <= marker_level:
|
|
101
|
+
end = heading_start
|
|
102
|
+
break
|
|
103
|
+
removals.append((marker_start, end))
|
|
104
|
+
|
|
105
|
+
if not removals:
|
|
106
|
+
return content
|
|
107
|
+
|
|
108
|
+
merged: list[tuple[int, int]] = []
|
|
109
|
+
for start, end in sorted(removals):
|
|
110
|
+
if not merged or start > merged[-1][1]:
|
|
111
|
+
merged.append((start, end))
|
|
112
|
+
else:
|
|
113
|
+
prev_start, prev_end = merged[-1]
|
|
114
|
+
merged[-1] = (prev_start, max(prev_end, end))
|
|
115
|
+
|
|
116
|
+
pieces: list[str] = []
|
|
117
|
+
cursor = 0
|
|
118
|
+
for start, end in merged:
|
|
119
|
+
pieces.append(content[cursor:start])
|
|
120
|
+
cursor = end
|
|
121
|
+
pieces.append(content[cursor:])
|
|
122
|
+
return "".join(pieces)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def heading_level(line: str) -> int | None:
|
|
126
|
+
"""ATX heading level (count of leading '#'), or None when not a heading."""
|
|
127
|
+
stripped = line.lstrip()
|
|
128
|
+
if not stripped.startswith("#"):
|
|
129
|
+
return None
|
|
130
|
+
return len(stripped) - len(stripped.lstrip("#"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def heading_bounded_bodies(content: str, is_start):
|
|
134
|
+
"""Yield each section whose heading line satisfies `is_start(line)`.
|
|
135
|
+
|
|
136
|
+
A section runs from its heading to the next heading at the same or higher
|
|
137
|
+
level, so a later sibling section cannot bleed into it. Headings are
|
|
138
|
+
located outside fenced code; each yielded body is a slice of the original
|
|
139
|
+
`content` (fences within the body are preserved for the caller to handle).
|
|
140
|
+
"""
|
|
141
|
+
lines = list(unfenced_markdown_lines(content))
|
|
142
|
+
starts = [
|
|
143
|
+
(start, level)
|
|
144
|
+
for line, start, _ in lines
|
|
145
|
+
if (level := heading_level(line)) is not None and is_start(line)
|
|
146
|
+
]
|
|
147
|
+
headings = [
|
|
148
|
+
(start, level)
|
|
149
|
+
for line, start, _ in lines
|
|
150
|
+
if (level := heading_level(line)) is not None
|
|
151
|
+
]
|
|
152
|
+
for start, level in starts:
|
|
153
|
+
end = len(content)
|
|
154
|
+
for heading_start, heading_level_ in headings:
|
|
155
|
+
if heading_start <= start:
|
|
156
|
+
continue
|
|
157
|
+
if heading_level_ <= level:
|
|
158
|
+
end = heading_start
|
|
159
|
+
break
|
|
160
|
+
yield content[start:end]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from tools.workflow_cli.models import Stage, TierBase
|
|
4
|
+
|
|
5
|
+
# Structural fields every PLAN-TASK anchor must carry (gate + trace share these).
|
|
6
|
+
PLAN_TASK_FIELDS = (
|
|
7
|
+
"Spec References",
|
|
8
|
+
"Change Type",
|
|
9
|
+
"TDD Applicable",
|
|
10
|
+
"Files",
|
|
11
|
+
"Skeleton",
|
|
12
|
+
"Steps",
|
|
13
|
+
"Verification",
|
|
14
|
+
)
|
|
15
|
+
# Matches a line that opens one of those fields, e.g. "Files: src/a.py".
|
|
16
|
+
PLAN_TASK_FIELD_RE = re.compile(r"^(" + "|".join(PLAN_TASK_FIELDS) + r"):")
|
|
17
|
+
|
|
18
|
+
# Headings MUST stay byte-identical to any existing gate regex they overlap.
|
|
19
|
+
# Notably "## External Documentation Checked" matches gates.py:_EXTERNAL_DOCS_RE.
|
|
20
|
+
STAGE_SCHEMA: dict = {
|
|
21
|
+
Stage.REQUIREMENT_BRIEF: {
|
|
22
|
+
TierBase.LIGHT: ["## Goal", "## In-Scope", "## Out-of-Scope", "## Acceptance Criteria"],
|
|
23
|
+
TierBase.STANDARD: [
|
|
24
|
+
"## Goal", "## In-Scope", "## Out-of-Scope", "## Non-Goals",
|
|
25
|
+
"## Assumptions", "## Acceptance Criteria", "## Open Questions", "## Sources",
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
Stage.RISK_DISCOVERY: {
|
|
29
|
+
TierBase.LIGHT: ["## Risks", "## Boundaries"],
|
|
30
|
+
TierBase.STANDARD: ["## Risks", "## Boundaries", "## Scope Overflow Risks", "## Mitigations"],
|
|
31
|
+
},
|
|
32
|
+
Stage.DESIGN: {
|
|
33
|
+
TierBase.LIGHT: ["## Design Summary", "## Chosen Design", "## SPEC Handoff"],
|
|
34
|
+
TierBase.STANDARD: [
|
|
35
|
+
"## Design Summary", "## Current Code Evidence", "## Requirements Coverage",
|
|
36
|
+
"## Options Considered", "## Chosen Design", "## Rollback",
|
|
37
|
+
"## Observability", "## SPEC Handoff",
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
Stage.SPEC: {
|
|
41
|
+
TierBase.LIGHT: ["## Behavior Contracts", "## External Documentation Checked", "## PLAN Handoff"],
|
|
42
|
+
TierBase.STANDARD: [
|
|
43
|
+
"## Behavior Contracts", "## API / Data / Config Contracts",
|
|
44
|
+
"## External Documentation Checked", "## Test Matrix", "## Non-goals", "## PLAN Handoff",
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
Stage.PLAN: {
|
|
48
|
+
# PLAN's substantive checks (task coverage, fields) live in R5, not R2.
|
|
49
|
+
TierBase.LIGHT: ["## Tasks"],
|
|
50
|
+
TierBase.STANDARD: ["## Tasks"],
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def required_headings(stage: Stage, tier_base: TierBase) -> list[str]:
|
|
56
|
+
"""Required top-level headings for a stage at a tier base; [] if unschema'd."""
|
|
57
|
+
return list(STAGE_SCHEMA.get(stage, {}).get(tier_base, []))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Render STAGE_SCHEMA into per-stage, per-tier seed templates.
|
|
2
|
+
|
|
3
|
+
The CLI writes these into the stage content file so the agent starts from a
|
|
4
|
+
structured skeleton, not a blank page. Templates carry no semantic claims —
|
|
5
|
+
only headings, required gate anchors, example ID shapes, and a static trace-table skeleton.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from tools.workflow_cli.models import Stage, TierBase
|
|
10
|
+
from tools.workflow_cli.stage_schema import required_headings
|
|
11
|
+
|
|
12
|
+
_TRACE_SKELETON = (
|
|
13
|
+
"## Trace\n"
|
|
14
|
+
"<!-- Map this stage's IDs to upstream/downstream. R3 derives & checks closure. -->\n"
|
|
15
|
+
"| This ID | Upstream | Status |\n"
|
|
16
|
+
"|---|---|---|\n"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_HEADING_BODY = {
|
|
21
|
+
(Stage.REQUIREMENT_BRIEF, "## In-Scope"): "- SCOPE-IN-001 <!-- fill in -->\n",
|
|
22
|
+
(Stage.REQUIREMENT_BRIEF, "## Out-of-Scope"): "- SCOPE-OUT-001 <!-- fill in -->\n",
|
|
23
|
+
(Stage.RISK_DISCOVERY, "## Risks"): "### RISK-SEC-001 <!-- fill in -->\nStatus: <!-- fill in -->\n",
|
|
24
|
+
(Stage.DESIGN, "## Chosen Design"): "### DES-ARCH-001 <!-- fill in -->\n",
|
|
25
|
+
(Stage.SPEC, "## Behavior Contracts"): "### SPEC-BEHAVIOR-001 <!-- fill in -->\n",
|
|
26
|
+
(Stage.PLAN, "## Tasks"): (
|
|
27
|
+
"### PLAN-TASK-001 <!-- fill in -->\n"
|
|
28
|
+
"Spec References: SPEC-BEHAVIOR-001\n"
|
|
29
|
+
"Change Type: modify\n"
|
|
30
|
+
"TDD Applicable: yes\n"
|
|
31
|
+
"Files:\n"
|
|
32
|
+
"- <!-- fill in -->\n"
|
|
33
|
+
"Skeleton:\n"
|
|
34
|
+
"```python\n"
|
|
35
|
+
"# <!-- fill in -->\n"
|
|
36
|
+
"```\n"
|
|
37
|
+
"Steps:\n"
|
|
38
|
+
"- [ ] <!-- fill in -->\n"
|
|
39
|
+
"Verification: <!-- fill in -->\n"
|
|
40
|
+
),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _body_for(stage: Stage, heading: str) -> str:
|
|
45
|
+
return _HEADING_BODY.get((stage, heading), "<!-- fill in -->\n")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def template_for(stage: Stage, tier_base: TierBase) -> str:
|
|
49
|
+
headings = required_headings(stage, tier_base)
|
|
50
|
+
title = stage.value.replace("_", " ").title()
|
|
51
|
+
parts = [f"# {title}\n"]
|
|
52
|
+
for h in headings:
|
|
53
|
+
parts.append(f"{h}\n{_body_for(stage, h)}")
|
|
54
|
+
parts.append(_TRACE_SKELETON)
|
|
55
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Cross-stage trace coverage (R3). Derived from artifacts; no stored matrix."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tools.workflow_cli.markdown import (
|
|
9
|
+
heading_bounded_bodies,
|
|
10
|
+
heading_level,
|
|
11
|
+
strip_readonly_sections,
|
|
12
|
+
unfenced_markdown_lines,
|
|
13
|
+
unfenced_markdown_text,
|
|
14
|
+
)
|
|
15
|
+
from tools.workflow_cli.models import STAGE_ARTIFACT_MAP, Stage
|
|
16
|
+
from tools.workflow_cli.stage_schema import PLAN_TASK_FIELD_RE
|
|
17
|
+
|
|
18
|
+
# REQ-AUTH-001 / RISK-SEC-001 / DES-AUTH-001 / SPEC-AUTH-001 / SCOPE-IN-001 / SCOPE-OUT-001 / PLAN-TASK-001
|
|
19
|
+
_ID_RE = re.compile(r"(?:REQ|RISK|DES|SPEC)-[A-Z]+-\d+|SCOPE-(?:IN|OUT)-\d+|PLAN-TASK-\d+")
|
|
20
|
+
_PLAN_TASK_HEADING_RE = re.compile(r"^###\s+PLAN-TASK-\d+\b")
|
|
21
|
+
_NATIVE_HEADING_ID_PREFIXES: dict[Stage, tuple[str, ...]] = {
|
|
22
|
+
Stage.RAW_REQUIREMENT: ("REQ-",),
|
|
23
|
+
Stage.RISK_DISCOVERY: ("RISK-",),
|
|
24
|
+
Stage.DESIGN: ("DES-",),
|
|
25
|
+
Stage.SPEC: ("SPEC-",),
|
|
26
|
+
Stage.PLAN: ("PLAN-TASK-",),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TraceModel:
|
|
32
|
+
defined: dict = field(default_factory=dict) # id -> stage value where first defined
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _scope_ids_defined_in_brief(stage: Stage, content: str) -> set[str]:
|
|
36
|
+
"""SCOPE-* ids are defined by bullet entries in the brief scope sections."""
|
|
37
|
+
if stage != Stage.REQUIREMENT_BRIEF:
|
|
38
|
+
return set()
|
|
39
|
+
ids: set[str] = set()
|
|
40
|
+
capture = False
|
|
41
|
+
capture_level = 0
|
|
42
|
+
for line, _, _ in unfenced_markdown_lines(content):
|
|
43
|
+
stripped = line.strip()
|
|
44
|
+
level = heading_level(line)
|
|
45
|
+
if stripped in {"## In-Scope", "## Out-of-Scope"}:
|
|
46
|
+
capture = True
|
|
47
|
+
capture_level = level or 0
|
|
48
|
+
continue
|
|
49
|
+
if capture and level is not None and level <= capture_level:
|
|
50
|
+
capture = False
|
|
51
|
+
if capture:
|
|
52
|
+
ids.update(m.group(0) for m in _ID_RE.finditer(line) if m.group(0).startswith("SCOPE-"))
|
|
53
|
+
return ids
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _native_heading_ids(stage: Stage, content: str) -> set[str]:
|
|
57
|
+
prefixes = _NATIVE_HEADING_ID_PREFIXES.get(stage, ())
|
|
58
|
+
if not prefixes:
|
|
59
|
+
return set()
|
|
60
|
+
ids: set[str] = set()
|
|
61
|
+
for line, _, _ in unfenced_markdown_lines(content):
|
|
62
|
+
if line.lstrip().startswith("#"):
|
|
63
|
+
for match in _ID_RE.finditer(line):
|
|
64
|
+
id_ = match.group(0)
|
|
65
|
+
if id_.startswith(prefixes):
|
|
66
|
+
ids.add(id_)
|
|
67
|
+
return ids
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _artifact_text(run_dir: Path, stage: Stage) -> str:
|
|
71
|
+
path = run_dir / STAGE_ARTIFACT_MAP[stage]
|
|
72
|
+
return strip_readonly_sections(path.read_text(encoding="utf-8")) if path.exists() else ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _plan_task_bodies(plan_content: str):
|
|
76
|
+
return heading_bounded_bodies(plan_content, _PLAN_TASK_HEADING_RE.match)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _find_plan_task_field(body: str, field: str):
|
|
80
|
+
field_re = re.compile(rf"^{re.escape(field)}:\s*(.*)$")
|
|
81
|
+
for line, start, _ in unfenced_markdown_lines(body):
|
|
82
|
+
m = field_re.match(line)
|
|
83
|
+
if m:
|
|
84
|
+
return m, start
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _find_next_plan_task_field_start(body: str, after: int) -> int | None:
|
|
89
|
+
for line, start, _ in unfenced_markdown_lines(body):
|
|
90
|
+
if start >= after and PLAN_TASK_FIELD_RE.match(line):
|
|
91
|
+
return start
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _plan_task_field_value(body: str, field: str) -> str:
|
|
96
|
+
found = _find_plan_task_field(body, field)
|
|
97
|
+
if found is None:
|
|
98
|
+
return ""
|
|
99
|
+
match, line_start = found
|
|
100
|
+
body_start = line_start + match.end()
|
|
101
|
+
next_start = _find_next_plan_task_field_start(body, body_start)
|
|
102
|
+
end = next_start if next_start is not None else len(body)
|
|
103
|
+
return f"{match.group(1)}\n{body[body_start:end]}".strip()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def plan_consumed_spec_ids(run_dir: Path) -> set[str]:
|
|
107
|
+
"""SPEC IDs consumed by PLAN-TASK Spec References fields, not merely mentioned."""
|
|
108
|
+
plan = _artifact_text(run_dir, Stage.PLAN)
|
|
109
|
+
consumed: set[str] = set()
|
|
110
|
+
for body in _plan_task_bodies(plan):
|
|
111
|
+
consumed.update(m.group(0) for m in _ID_RE.finditer(_plan_task_field_value(body, "Spec References"))
|
|
112
|
+
if m.group(0).startswith("SPEC-"))
|
|
113
|
+
return consumed
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _heading_blocks(content: str, id_pattern: str) -> dict[str, str]:
|
|
117
|
+
"""Map each `id_pattern` ID defined in a heading to its section text.
|
|
118
|
+
|
|
119
|
+
A block runs from its heading to the next same-or-higher heading, so a
|
|
120
|
+
later sibling section cannot bleed its references into the block. Fenced
|
|
121
|
+
code is ignored.
|
|
122
|
+
"""
|
|
123
|
+
content = unfenced_markdown_text(content)
|
|
124
|
+
starts = list(re.finditer(rf"(?m)^(#+)\s+.*?\b({id_pattern})\b", content))
|
|
125
|
+
headings = list(re.finditer(r"(?m)^(#+)\s+", content))
|
|
126
|
+
blocks: dict[str, str] = {}
|
|
127
|
+
for match in starts:
|
|
128
|
+
level = len(match.group(1))
|
|
129
|
+
end = len(content)
|
|
130
|
+
for heading in headings:
|
|
131
|
+
if heading.start() <= match.start():
|
|
132
|
+
continue
|
|
133
|
+
if len(heading.group(1)) <= level:
|
|
134
|
+
end = heading.start()
|
|
135
|
+
break
|
|
136
|
+
blocks[match.group(2)] = content[match.start():end]
|
|
137
|
+
return blocks
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _spec_blocks(spec_content: str) -> dict[str, str]:
|
|
141
|
+
return _heading_blocks(spec_content, r"SPEC-[A-Z]+-\d+")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _risk_blocks(content: str) -> dict[str, str]:
|
|
145
|
+
return _heading_blocks(content, r"RISK-[A-Z]+-\d+")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def scope_in_not_closed(run_dir: Path) -> list[str]:
|
|
149
|
+
"""SCOPE-IN closes only when a PLAN-TASK carries it or consumes a SPEC carrying it."""
|
|
150
|
+
model = build_trace(run_dir)
|
|
151
|
+
plan_text = _artifact_text(run_dir, Stage.PLAN)
|
|
152
|
+
plan_task_text = "\n".join(unfenced_markdown_text(body) for body in _plan_task_bodies(plan_text))
|
|
153
|
+
spec_blocks = _spec_blocks(_artifact_text(run_dir, Stage.SPEC))
|
|
154
|
+
consumed_specs = plan_consumed_spec_ids(run_dir)
|
|
155
|
+
issues: list[str] = []
|
|
156
|
+
for id_ in sorted(i for i in model.defined if i.startswith("SCOPE-IN-")):
|
|
157
|
+
if id_ in plan_task_text:
|
|
158
|
+
continue
|
|
159
|
+
if any(id_ in spec_blocks.get(spec_id, "") for spec_id in consumed_specs):
|
|
160
|
+
continue
|
|
161
|
+
issues.append(id_)
|
|
162
|
+
return issues
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def risk_ids_not_closed(run_dir: Path) -> list[str]:
|
|
166
|
+
"""RISK-* blocks must declare Status: mitigated|deferred|out_of_scope|out-of-scope.
|
|
167
|
+
|
|
168
|
+
Closure is read only from the risk definition block in RISK_DISCOVERY.
|
|
169
|
+
A block stops at the next same-or-higher Markdown heading, so unrelated
|
|
170
|
+
downstream Status fields cannot close an open risk.
|
|
171
|
+
"""
|
|
172
|
+
model = build_trace(run_dir)
|
|
173
|
+
blocks = _risk_blocks(_artifact_text(run_dir, Stage.RISK_DISCOVERY))
|
|
174
|
+
open_risks: list[str] = []
|
|
175
|
+
for id_ in sorted(i for i in model.defined if i.startswith("RISK-")):
|
|
176
|
+
if not re.search(r"(?m)^Status:\s*(mitigated|deferred|out(?:_of_scope|-of-scope))\s*$", blocks.get(id_, "")):
|
|
177
|
+
open_risks.append(id_)
|
|
178
|
+
return open_risks
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def build_trace(run_dir: Path) -> TraceModel:
|
|
182
|
+
model = TraceModel()
|
|
183
|
+
for stage, filename in STAGE_ARTIFACT_MAP.items():
|
|
184
|
+
path = run_dir / filename
|
|
185
|
+
if not path.exists():
|
|
186
|
+
continue
|
|
187
|
+
content = _artifact_text(run_dir, stage)
|
|
188
|
+
heading_ids = _native_heading_ids(stage, content)
|
|
189
|
+
definition_ids = heading_ids | _scope_ids_defined_in_brief(stage, content)
|
|
190
|
+
for id_ in definition_ids:
|
|
191
|
+
model.defined.setdefault(id_, stage.value)
|
|
192
|
+
return model
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def spec_ids_not_consumed(run_dir: Path) -> list[str]:
|
|
196
|
+
"""SPEC-* defined but not consumed by PLAN-TASK Spec References fields."""
|
|
197
|
+
model = build_trace(run_dir)
|
|
198
|
+
consumed = plan_consumed_spec_ids(run_dir)
|
|
199
|
+
return sorted(id_ for id_, _ in model.defined.items()
|
|
200
|
+
if id_.startswith("SPEC-") and id_ not in consumed)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def scope_out_violations(run_dir: Path) -> list[str]:
|
|
204
|
+
"""SCOPE-OUT-* ids that executable PLAN-TASK bodies reference — a scope overflow (R8)."""
|
|
205
|
+
plan_text = _artifact_text(run_dir, Stage.PLAN)
|
|
206
|
+
plan_task_text = "\n".join(unfenced_markdown_text(body) for body in _plan_task_bodies(plan_text))
|
|
207
|
+
return sorted(
|
|
208
|
+
{
|
|
209
|
+
m.group(0)
|
|
210
|
+
for m in _ID_RE.finditer(plan_task_text)
|
|
211
|
+
if m.group(0).startswith("SCOPE-OUT-")
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def check_trace_closure(run_dir: Path) -> list[str]:
|
|
217
|
+
issues: list[str] = []
|
|
218
|
+
for id_ in spec_ids_not_consumed(run_dir):
|
|
219
|
+
issues.append(f"SPEC {id_} is not consumed by any PLAN-TASK (coverage gap).")
|
|
220
|
+
for id_ in scope_in_not_closed(run_dir):
|
|
221
|
+
issues.append(f"In-scope item {id_} is not carried into PLAN consumption (scope not closed).")
|
|
222
|
+
for id_ in risk_ids_not_closed(run_dir):
|
|
223
|
+
issues.append(f"Risk {id_} is not mitigated, deferred, or marked out-of-scope (risk not closed).")
|
|
224
|
+
return issues
|
|
@@ -1 +1 @@
|
|
|
1
|
-
R2P_VERSION = "0.
|
|
1
|
+
R2P_VERSION = "0.3.0"
|