@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,504 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core data models for the req-to-plan workflow CLI.
|
|
3
|
+
Python 3.10+ required (uses X | Y union syntax).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from tools.workflow_cli.version import R2P_VERSION
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Enums
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RunStatus(str, Enum):
|
|
23
|
+
NOT_STARTED = "not_started"
|
|
24
|
+
ACTIVE_STAGE_DRAFT = "active_stage_draft"
|
|
25
|
+
ENTRY_GATE_FAILED = "entry_gate_failed"
|
|
26
|
+
QUALITY_GATE_FAILED = "quality_gate_failed"
|
|
27
|
+
READY_FOR_CHECKPOINT_REVIEW = "ready_for_checkpoint_review"
|
|
28
|
+
CHECKPOINT_REVIEW = "checkpoint_review"
|
|
29
|
+
CHECKPOINT_CHANGES_REQUESTED = "checkpoint_changes_requested"
|
|
30
|
+
UPSTREAM_GAP_ROUTING = "upstream_gap_routing"
|
|
31
|
+
CHECKPOINT_APPROVED = "checkpoint_approved"
|
|
32
|
+
NEXT_STAGE = "next_stage"
|
|
33
|
+
CLOSED_AT_PLAN_CHECKPOINT = "closed_at_plan_checkpoint"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Stage(str, Enum):
|
|
37
|
+
RAW_REQUIREMENT = "raw_requirement"
|
|
38
|
+
REQUIREMENT_BRIEF = "requirement_brief"
|
|
39
|
+
RISK_DISCOVERY = "risk_discovery"
|
|
40
|
+
DESIGN = "design"
|
|
41
|
+
SPEC = "spec"
|
|
42
|
+
PLAN = "plan"
|
|
43
|
+
CLOSED = "closed"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TierBase(str, Enum):
|
|
47
|
+
LIGHT = "light"
|
|
48
|
+
STANDARD = "standard"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TierModifier(str, Enum):
|
|
52
|
+
MIGRATION = "migration"
|
|
53
|
+
CROSS_PROJECT = "cross_project"
|
|
54
|
+
SAFETY = "safety"
|
|
55
|
+
DEPENDENCY = "dependency"
|
|
56
|
+
SCOPE_EXPANDING = "scope_expanding"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Stage constants
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
STAGE_ORDER: list[Stage] = [
|
|
64
|
+
Stage.RAW_REQUIREMENT,
|
|
65
|
+
Stage.REQUIREMENT_BRIEF,
|
|
66
|
+
Stage.RISK_DISCOVERY,
|
|
67
|
+
Stage.DESIGN,
|
|
68
|
+
Stage.SPEC,
|
|
69
|
+
Stage.PLAN,
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
NEXT_STAGE_MAP: dict[Stage, Stage] = {
|
|
73
|
+
Stage.RAW_REQUIREMENT: Stage.REQUIREMENT_BRIEF,
|
|
74
|
+
Stage.REQUIREMENT_BRIEF: Stage.RISK_DISCOVERY,
|
|
75
|
+
Stage.RISK_DISCOVERY: Stage.DESIGN,
|
|
76
|
+
Stage.DESIGN: Stage.SPEC,
|
|
77
|
+
Stage.SPEC: Stage.PLAN,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
STAGE_ARTIFACT_MAP: dict[Stage, str] = {
|
|
81
|
+
Stage.RAW_REQUIREMENT: "00-raw-requirement.md",
|
|
82
|
+
Stage.REQUIREMENT_BRIEF: "03-requirement-brief.md",
|
|
83
|
+
Stage.RISK_DISCOVERY: "04-risk-discovery.md",
|
|
84
|
+
Stage.DESIGN: "05-design.md",
|
|
85
|
+
Stage.SPEC: "06-spec.md",
|
|
86
|
+
Stage.PLAN: "07-plan.md",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
STAGE_REQUIRED_UPSTREAM_CHECKPOINTS: dict[Stage, list[Stage]] = {
|
|
90
|
+
Stage.RAW_REQUIREMENT: [],
|
|
91
|
+
Stage.REQUIREMENT_BRIEF: [],
|
|
92
|
+
Stage.RISK_DISCOVERY: [Stage.REQUIREMENT_BRIEF],
|
|
93
|
+
Stage.DESIGN: [Stage.REQUIREMENT_BRIEF, Stage.RISK_DISCOVERY],
|
|
94
|
+
Stage.SPEC: [Stage.REQUIREMENT_BRIEF, Stage.RISK_DISCOVERY, Stage.DESIGN],
|
|
95
|
+
Stage.PLAN: [Stage.REQUIREMENT_BRIEF, Stage.RISK_DISCOVERY, Stage.DESIGN, Stage.SPEC],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# State machine
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
ALLOWED_TRANSITIONS: dict[RunStatus, set[RunStatus]] = {
|
|
104
|
+
RunStatus.NOT_STARTED: {RunStatus.ACTIVE_STAGE_DRAFT},
|
|
105
|
+
RunStatus.ACTIVE_STAGE_DRAFT: {
|
|
106
|
+
RunStatus.ACTIVE_STAGE_DRAFT,
|
|
107
|
+
RunStatus.ENTRY_GATE_FAILED,
|
|
108
|
+
RunStatus.QUALITY_GATE_FAILED,
|
|
109
|
+
RunStatus.READY_FOR_CHECKPOINT_REVIEW,
|
|
110
|
+
RunStatus.UPSTREAM_GAP_ROUTING,
|
|
111
|
+
},
|
|
112
|
+
RunStatus.ENTRY_GATE_FAILED: {
|
|
113
|
+
RunStatus.ACTIVE_STAGE_DRAFT,
|
|
114
|
+
RunStatus.UPSTREAM_GAP_ROUTING,
|
|
115
|
+
},
|
|
116
|
+
RunStatus.QUALITY_GATE_FAILED: {
|
|
117
|
+
RunStatus.ACTIVE_STAGE_DRAFT,
|
|
118
|
+
RunStatus.UPSTREAM_GAP_ROUTING,
|
|
119
|
+
},
|
|
120
|
+
RunStatus.READY_FOR_CHECKPOINT_REVIEW: {
|
|
121
|
+
RunStatus.ACTIVE_STAGE_DRAFT,
|
|
122
|
+
RunStatus.CHECKPOINT_REVIEW,
|
|
123
|
+
RunStatus.UPSTREAM_GAP_ROUTING,
|
|
124
|
+
},
|
|
125
|
+
RunStatus.CHECKPOINT_REVIEW: {
|
|
126
|
+
RunStatus.ACTIVE_STAGE_DRAFT,
|
|
127
|
+
RunStatus.CHECKPOINT_CHANGES_REQUESTED,
|
|
128
|
+
RunStatus.CHECKPOINT_APPROVED,
|
|
129
|
+
RunStatus.UPSTREAM_GAP_ROUTING,
|
|
130
|
+
},
|
|
131
|
+
RunStatus.CHECKPOINT_CHANGES_REQUESTED: {
|
|
132
|
+
RunStatus.ACTIVE_STAGE_DRAFT,
|
|
133
|
+
RunStatus.QUALITY_GATE_FAILED,
|
|
134
|
+
RunStatus.UPSTREAM_GAP_ROUTING,
|
|
135
|
+
},
|
|
136
|
+
RunStatus.UPSTREAM_GAP_ROUTING: {
|
|
137
|
+
RunStatus.ACTIVE_STAGE_DRAFT,
|
|
138
|
+
RunStatus.UPSTREAM_GAP_ROUTING,
|
|
139
|
+
RunStatus.READY_FOR_CHECKPOINT_REVIEW,
|
|
140
|
+
RunStatus.CHECKPOINT_APPROVED,
|
|
141
|
+
},
|
|
142
|
+
RunStatus.CHECKPOINT_APPROVED: {
|
|
143
|
+
RunStatus.NEXT_STAGE,
|
|
144
|
+
RunStatus.CLOSED_AT_PLAN_CHECKPOINT,
|
|
145
|
+
RunStatus.UPSTREAM_GAP_ROUTING,
|
|
146
|
+
},
|
|
147
|
+
RunStatus.NEXT_STAGE: {
|
|
148
|
+
RunStatus.ACTIVE_STAGE_DRAFT,
|
|
149
|
+
RunStatus.ENTRY_GATE_FAILED,
|
|
150
|
+
},
|
|
151
|
+
RunStatus.CLOSED_AT_PLAN_CHECKPOINT: set(),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def is_transition_allowed(from_status: RunStatus, to_status: RunStatus) -> bool:
|
|
156
|
+
return to_status in ALLOWED_TRANSITIONS.get(from_status, set())
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Command eligibility
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
READ_ONLY_COMMANDS: set[str] = {
|
|
164
|
+
"CMD-STATUS-RUN",
|
|
165
|
+
"CMD-STATUS-STAGE",
|
|
166
|
+
"CMD-STATUS-NEXT",
|
|
167
|
+
"CMD-STATUS-ROUTES",
|
|
168
|
+
"CMD-STATUS-ARTIFACTS",
|
|
169
|
+
"CMD-TIER-STATUS",
|
|
170
|
+
"CMD-RUN-RESUME",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
ALLOWED_COMMANDS_BY_RUN_STATE: dict[RunStatus, set[str]] = {
|
|
174
|
+
RunStatus.NOT_STARTED: {"CMD-RUN-START"},
|
|
175
|
+
RunStatus.ACTIVE_STAGE_DRAFT: {
|
|
176
|
+
"CMD-STAGE-LOAD",
|
|
177
|
+
"CMD-STAGE-PRODUCE",
|
|
178
|
+
"CMD-STAGE-READY",
|
|
179
|
+
"CMD-GATE-ENTRY",
|
|
180
|
+
"CMD-GATE-QUALITY",
|
|
181
|
+
"CMD-CONFIRM-RECORD",
|
|
182
|
+
"CMD-CONFIRM-REJECT",
|
|
183
|
+
"CMD-CONFIRM-LINK",
|
|
184
|
+
"CMD-SUBAGENT-DISPATCH",
|
|
185
|
+
"CMD-SUBAGENT-MERGE",
|
|
186
|
+
"CMD-GAP-RECORD",
|
|
187
|
+
"CMD-TIER-ESTIMATE",
|
|
188
|
+
"CMD-TIER-LOCK",
|
|
189
|
+
"CMD-TIER-ESCALATE",
|
|
190
|
+
"CMD-TIER-STATUS",
|
|
191
|
+
},
|
|
192
|
+
RunStatus.ENTRY_GATE_FAILED: {
|
|
193
|
+
"CMD-GATE-ENTRY",
|
|
194
|
+
"CMD-CONFIRM-RECORD",
|
|
195
|
+
"CMD-CONFIRM-REJECT",
|
|
196
|
+
"CMD-GAP-RECORD",
|
|
197
|
+
"CMD-GAP-ROUTE",
|
|
198
|
+
"CMD-TIER-ESTIMATE",
|
|
199
|
+
"CMD-TIER-ESCALATE",
|
|
200
|
+
"CMD-TIER-STATUS",
|
|
201
|
+
},
|
|
202
|
+
RunStatus.QUALITY_GATE_FAILED: {
|
|
203
|
+
"CMD-STAGE-PRODUCE",
|
|
204
|
+
"CMD-STAGE-UPDATE",
|
|
205
|
+
"CMD-STAGE-READY",
|
|
206
|
+
"CMD-CONFIRM-RECORD",
|
|
207
|
+
"CMD-CONFIRM-REJECT",
|
|
208
|
+
"CMD-SUBAGENT-DISPATCH",
|
|
209
|
+
"CMD-SUBAGENT-MERGE",
|
|
210
|
+
"CMD-GAP-RECORD",
|
|
211
|
+
"CMD-GAP-ROUTE",
|
|
212
|
+
"CMD-TIER-ESCALATE",
|
|
213
|
+
"CMD-TIER-STATUS",
|
|
214
|
+
},
|
|
215
|
+
RunStatus.READY_FOR_CHECKPOINT_REVIEW: {
|
|
216
|
+
"CMD-STAGE-UPDATE",
|
|
217
|
+
"CMD-REVIEW-CHECKPOINT",
|
|
218
|
+
"CMD-SUBAGENT-REVIEW",
|
|
219
|
+
"CMD-GAP-RECORD",
|
|
220
|
+
"CMD-TIER-ESCALATE",
|
|
221
|
+
"CMD-TIER-STATUS",
|
|
222
|
+
},
|
|
223
|
+
RunStatus.CHECKPOINT_REVIEW: {
|
|
224
|
+
"CMD-REVIEW-CHECKPOINT",
|
|
225
|
+
"CMD-SUBAGENT-REVIEW",
|
|
226
|
+
"CMD-REVIEW-MERGE",
|
|
227
|
+
"CMD-CONFIRM-RECORD",
|
|
228
|
+
"CMD-CONFIRM-REJECT",
|
|
229
|
+
"CMD-CONFIRM-LINK",
|
|
230
|
+
"CMD-CHECKPOINT-DECIDE",
|
|
231
|
+
"CMD-CHECKPOINT-BUNDLE",
|
|
232
|
+
"CMD-GAP-RECORD",
|
|
233
|
+
"CMD-GAP-ROUTE",
|
|
234
|
+
"CMD-TIER-ESCALATE",
|
|
235
|
+
"CMD-TIER-STATUS",
|
|
236
|
+
},
|
|
237
|
+
RunStatus.CHECKPOINT_CHANGES_REQUESTED: {
|
|
238
|
+
"CMD-STAGE-PRODUCE",
|
|
239
|
+
"CMD-STAGE-UPDATE",
|
|
240
|
+
"CMD-CONFIRM-RECORD",
|
|
241
|
+
"CMD-CONFIRM-REJECT",
|
|
242
|
+
"CMD-SUBAGENT-DISPATCH",
|
|
243
|
+
"CMD-SUBAGENT-MERGE",
|
|
244
|
+
"CMD-GAP-RECORD",
|
|
245
|
+
"CMD-GAP-ROUTE",
|
|
246
|
+
"CMD-TIER-ESCALATE",
|
|
247
|
+
"CMD-TIER-STATUS",
|
|
248
|
+
},
|
|
249
|
+
RunStatus.UPSTREAM_GAP_ROUTING: {
|
|
250
|
+
"CMD-GAP-RECORD",
|
|
251
|
+
"CMD-GAP-ROUTE",
|
|
252
|
+
"CMD-GAP-REIMPORT",
|
|
253
|
+
"CMD-ARTIFACT-MARK-STALE",
|
|
254
|
+
"CMD-CONFIRM-RECORD",
|
|
255
|
+
"CMD-CONFIRM-REJECT",
|
|
256
|
+
"CMD-TIER-ESCALATE",
|
|
257
|
+
"CMD-TIER-STATUS",
|
|
258
|
+
},
|
|
259
|
+
RunStatus.CHECKPOINT_APPROVED: {
|
|
260
|
+
"CMD-RUN-CLOSE",
|
|
261
|
+
"CMD-STAGE-ADVANCE",
|
|
262
|
+
"CMD-TIER-STATUS",
|
|
263
|
+
},
|
|
264
|
+
RunStatus.NEXT_STAGE: {
|
|
265
|
+
"CMD-GATE-ENTRY",
|
|
266
|
+
"CMD-TIER-ESCALATE",
|
|
267
|
+
"CMD-TIER-STATUS",
|
|
268
|
+
},
|
|
269
|
+
RunStatus.CLOSED_AT_PLAN_CHECKPOINT: {
|
|
270
|
+
"CMD-RUN-REOPEN",
|
|
271
|
+
"CMD-TIER-STATUS",
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def is_command_allowed(status: RunStatus, command: str) -> bool:
|
|
277
|
+
if command in READ_ONLY_COMMANDS:
|
|
278
|
+
return True
|
|
279
|
+
return command in ALLOWED_COMMANDS_BY_RUN_STATE.get(status, set())
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
# WorkId
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
class WorkId(str):
|
|
287
|
+
_PATTERN = re.compile(r"^WF-\d{8}-[a-z0-9][a-z0-9\-]{1,38}[a-z0-9]$")
|
|
288
|
+
|
|
289
|
+
def __new__(cls, value: str) -> "WorkId":
|
|
290
|
+
if not cls._PATTERN.match(value):
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"Invalid WorkId format: {value!r}. Must match WF-YYYYMMDD-slug"
|
|
293
|
+
)
|
|
294
|
+
return str.__new__(cls, value)
|
|
295
|
+
|
|
296
|
+
@classmethod
|
|
297
|
+
def generate(cls, requirement: str) -> "WorkId":
|
|
298
|
+
date_str = datetime.now().strftime("%Y%m%d")
|
|
299
|
+
# Try to create slug from ASCII words
|
|
300
|
+
slug = re.sub(r"[^a-zA-Z0-9\s]", "", requirement).lower().strip()
|
|
301
|
+
words = slug.split()
|
|
302
|
+
if words:
|
|
303
|
+
candidate = "-".join(words[:6])[:40]
|
|
304
|
+
candidate = re.sub(r"-+", "-", candidate).strip("-")
|
|
305
|
+
if len(candidate) >= 2:
|
|
306
|
+
try:
|
|
307
|
+
return cls(f"WF-{date_str}-{candidate}")
|
|
308
|
+
except ValueError:
|
|
309
|
+
pass
|
|
310
|
+
# Fallback: hash-based slug
|
|
311
|
+
hash_suffix = hashlib.md5(requirement.encode()).hexdigest()[:8]
|
|
312
|
+
return cls(f"WF-{date_str}-run-{hash_suffix}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Tier models
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
_TIER_BASE_ORDER = [TierBase.LIGHT, TierBase.STANDARD]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass(frozen=True)
|
|
323
|
+
class TierEstimate:
|
|
324
|
+
base: TierBase
|
|
325
|
+
modifiers: frozenset[TierModifier] = field(default_factory=frozenset)
|
|
326
|
+
_floor_base: TierBase | None = field(default=None, compare=False, repr=False)
|
|
327
|
+
_floor_modifiers: frozenset[TierModifier] | None = field(
|
|
328
|
+
default=None, compare=False, repr=False
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def floor(self) -> "TierEstimate":
|
|
332
|
+
"""Return this estimate treated as its own floor (used for lock validation)."""
|
|
333
|
+
return TierEstimate(
|
|
334
|
+
base=self.base,
|
|
335
|
+
modifiers=self.modifiers,
|
|
336
|
+
_floor_base=self.base,
|
|
337
|
+
_floor_modifiers=self.modifiers,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def is_above_floor(self) -> bool:
|
|
341
|
+
"""True when this estimate is stricter than the recorded floor."""
|
|
342
|
+
if self._floor_base is None:
|
|
343
|
+
return False
|
|
344
|
+
if _TIER_BASE_ORDER.index(self.base) > _TIER_BASE_ORDER.index(self._floor_base):
|
|
345
|
+
return True
|
|
346
|
+
return self.modifiers > (self._floor_modifiers or frozenset())
|
|
347
|
+
|
|
348
|
+
def escalate(self, modifier: TierModifier) -> "TierEstimate":
|
|
349
|
+
"""Return a new estimate with the modifier added (immutable)."""
|
|
350
|
+
new_mods = self.modifiers | frozenset({modifier})
|
|
351
|
+
new_base = TierBase.STANDARD if modifier == TierModifier.SCOPE_EXPANDING else self.base
|
|
352
|
+
return TierEstimate(
|
|
353
|
+
base=new_base,
|
|
354
|
+
modifiers=new_mods,
|
|
355
|
+
_floor_base=self._floor_base,
|
|
356
|
+
_floor_modifiers=self._floor_modifiers,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def lock(
|
|
360
|
+
self,
|
|
361
|
+
user_base: TierBase,
|
|
362
|
+
user_modifiers: frozenset[TierModifier],
|
|
363
|
+
) -> "TierEstimate":
|
|
364
|
+
"""Lock tier to user-specified values; raises ValueError if below floor."""
|
|
365
|
+
# Derives floor from self.base/self.modifiers (not from _floor_base which is only for is_above_floor)
|
|
366
|
+
floor = self.floor()
|
|
367
|
+
if _TIER_BASE_ORDER.index(user_base) < _TIER_BASE_ORDER.index(floor.base):
|
|
368
|
+
raise ValueError(
|
|
369
|
+
f"Cannot lock tier below floor: requested {user_base.value}, "
|
|
370
|
+
f"floor is {floor.base.value}"
|
|
371
|
+
)
|
|
372
|
+
if not user_modifiers.issuperset(floor.modifiers):
|
|
373
|
+
raise ValueError(
|
|
374
|
+
f"Missing required modifiers: {floor.modifiers - user_modifiers}"
|
|
375
|
+
)
|
|
376
|
+
return TierEstimate(
|
|
377
|
+
base=user_base,
|
|
378
|
+
modifiers=user_modifiers,
|
|
379
|
+
_floor_base=self.base,
|
|
380
|
+
_floor_modifiers=self.modifiers,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# Evidence block
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
@dataclass
|
|
389
|
+
class EvidenceBlock:
|
|
390
|
+
keywords_hit: list[str] | None = None
|
|
391
|
+
repo_baseline_summary: str | None = None
|
|
392
|
+
linked_context: str | None = None
|
|
393
|
+
scope_signals: list[str] | None = None
|
|
394
|
+
escalation_candidates: list[str] | None = None
|
|
395
|
+
floor: TierEstimate | None = None
|
|
396
|
+
confirm_status: str | None = None # "pending" | "confirmed" | "overridden"
|
|
397
|
+
|
|
398
|
+
def validate_for_lock(self) -> None:
|
|
399
|
+
required = ["keywords_hit", "repo_baseline_summary", "floor", "confirm_status"]
|
|
400
|
+
missing = [f for f in required if getattr(self, f) is None]
|
|
401
|
+
if missing:
|
|
402
|
+
raise ValueError(
|
|
403
|
+
f"EvidenceBlock missing required fields for lock: {missing}"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
# Bundle authorization
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
@dataclass
|
|
412
|
+
class BundleAuthorization:
|
|
413
|
+
bundle_id: str
|
|
414
|
+
stages: frozenset[Stage]
|
|
415
|
+
authorized_at: str
|
|
416
|
+
revoked_at: str | None = None
|
|
417
|
+
consumed_by_stage: dict[str, str] = field(default_factory=dict) # stage.value → approved_at
|
|
418
|
+
|
|
419
|
+
def is_live(self) -> bool:
|
|
420
|
+
if self.revoked_at is not None:
|
|
421
|
+
return False
|
|
422
|
+
consumed = {Stage(k) for k in self.consumed_by_stage}
|
|
423
|
+
return not self.stages.issubset(consumed)
|
|
424
|
+
|
|
425
|
+
def covers(self, stage: Stage) -> bool:
|
|
426
|
+
if not self.is_live():
|
|
427
|
+
return False
|
|
428
|
+
if stage not in self.stages:
|
|
429
|
+
return False
|
|
430
|
+
return stage.value not in self.consumed_by_stage
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
# Supporting dataclasses
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
@dataclass
|
|
438
|
+
class CheckpointRecord:
|
|
439
|
+
stage: Stage
|
|
440
|
+
artifact: str
|
|
441
|
+
version: int
|
|
442
|
+
approved_at: str
|
|
443
|
+
downstream_authorization: str
|
|
444
|
+
bundle_id: str | None = None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@dataclass
|
|
448
|
+
class ActiveArtifact:
|
|
449
|
+
stage: Stage
|
|
450
|
+
artifact: str
|
|
451
|
+
version: int
|
|
452
|
+
status: str # draft | ready | approved | stale
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@dataclass
|
|
456
|
+
class StaleArtifact:
|
|
457
|
+
artifact: str
|
|
458
|
+
reason: str
|
|
459
|
+
replaced_by: str
|
|
460
|
+
required_action: str
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@dataclass
|
|
464
|
+
class OpenRoute:
|
|
465
|
+
route_id: str
|
|
466
|
+
from_stage: Stage
|
|
467
|
+
owner_stage: Stage
|
|
468
|
+
required_action: str
|
|
469
|
+
status: str # open | repaired
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@dataclass
|
|
473
|
+
class UserConfirmation:
|
|
474
|
+
confirmation: str
|
|
475
|
+
stage: Stage
|
|
476
|
+
source: str
|
|
477
|
+
recorded_in: str
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@dataclass
|
|
481
|
+
class ResumeContext:
|
|
482
|
+
last_completed_operation: str = ""
|
|
483
|
+
next_allowed_operation: str = ""
|
|
484
|
+
active_item: str = ""
|
|
485
|
+
required_reread_targets: list[str] = field(default_factory=list)
|
|
486
|
+
resume_reason: str = ""
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@dataclass
|
|
490
|
+
class RunRecord:
|
|
491
|
+
work_id: WorkId
|
|
492
|
+
status: RunStatus = RunStatus.NOT_STARTED
|
|
493
|
+
current_stage: Stage = Stage.RAW_REQUIREMENT
|
|
494
|
+
r2p_version: str = R2P_VERSION
|
|
495
|
+
approved_checkpoints: list[CheckpointRecord] = field(default_factory=list)
|
|
496
|
+
bundle_authorizations: list[BundleAuthorization] = field(default_factory=list)
|
|
497
|
+
active_artifacts: list[ActiveArtifact] = field(default_factory=list)
|
|
498
|
+
stale_artifacts: list[StaleArtifact] = field(default_factory=list)
|
|
499
|
+
open_routes: list[OpenRoute] = field(default_factory=list)
|
|
500
|
+
user_confirmations: list[UserConfirmation] = field(default_factory=list)
|
|
501
|
+
resume_context: ResumeContext = field(default_factory=ResumeContext)
|
|
502
|
+
tier_estimate: TierEstimate | None = None
|
|
503
|
+
tier_locked: TierEstimate | None = None
|
|
504
|
+
reopen_lineage: str | None = None # e.g. "reopened_from: WF-...-r0@plan_checkpoint"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
# Exit codes
|
|
6
|
+
EXIT_OK = 0 # success
|
|
7
|
+
EXIT_CLI_ERR = 2 # CLI/input error (missing args, unknown command)
|
|
8
|
+
EXIT_GATE_FAIL = 3 # gate check failure (entry or quality gate)
|
|
9
|
+
EXIT_DRY_RUN = 4 # dry-run mode, no changes made
|
|
10
|
+
EXIT_REVIEW_REQ = 5 # forced subagent review required
|
|
11
|
+
EXIT_CONFLICT = 6 # state conflict (run already closed, etc.)
|
|
12
|
+
EXIT_NOT_FOUND = 7 # resource not found (run.md, artifact, etc.)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_json_mode() -> bool:
|
|
16
|
+
"""Check if JSON output mode is enabled via R2P_JSON environment variable."""
|
|
17
|
+
return os.environ.get("R2P_JSON", "0") == "1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def format_success(data: dict, message: str = "") -> str:
|
|
21
|
+
"""Format a success response."""
|
|
22
|
+
if is_json_mode():
|
|
23
|
+
return json.dumps({"status": "ok", "message": message, **data}, indent=2)
|
|
24
|
+
|
|
25
|
+
lines = []
|
|
26
|
+
if message:
|
|
27
|
+
lines.append(f"✓ {message}")
|
|
28
|
+
|
|
29
|
+
for key, value in data.items():
|
|
30
|
+
if isinstance(value, (list, dict)):
|
|
31
|
+
lines.append(f" {key}:")
|
|
32
|
+
if isinstance(value, list):
|
|
33
|
+
for item in value:
|
|
34
|
+
lines.append(f" - {item}")
|
|
35
|
+
else:
|
|
36
|
+
for k, v in value.items():
|
|
37
|
+
lines.append(f" {k}: {v}")
|
|
38
|
+
else:
|
|
39
|
+
lines.append(f" {key}: {value}")
|
|
40
|
+
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_error(message: str, details: list[str] | None = None, exit_code: int = EXIT_CLI_ERR) -> str:
|
|
45
|
+
"""Format an error response."""
|
|
46
|
+
if is_json_mode():
|
|
47
|
+
payload = {"status": "error", "message": message, "exit_code": exit_code}
|
|
48
|
+
if details:
|
|
49
|
+
payload["details"] = details
|
|
50
|
+
return json.dumps(payload, indent=2)
|
|
51
|
+
|
|
52
|
+
lines = [f"✗ {message}"]
|
|
53
|
+
if details:
|
|
54
|
+
for d in details:
|
|
55
|
+
lines.append(f" • {d}")
|
|
56
|
+
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_stop(reason: str, next_step: str = "", data: dict | None = None) -> str:
|
|
61
|
+
"""Format a stop condition (workflow needs user input)."""
|
|
62
|
+
if is_json_mode():
|
|
63
|
+
payload = {"status": "stop", "reason": reason}
|
|
64
|
+
if next_step:
|
|
65
|
+
payload["next_step"] = next_step
|
|
66
|
+
if data:
|
|
67
|
+
payload.update(data)
|
|
68
|
+
return json.dumps(payload, indent=2)
|
|
69
|
+
|
|
70
|
+
lines = [f"⏸ {reason}"]
|
|
71
|
+
if next_step:
|
|
72
|
+
lines.append(f" → {next_step}")
|
|
73
|
+
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def format_gate_result(gate_result, gate_type: str = "gate") -> str:
|
|
78
|
+
"""Format a GateResult for output."""
|
|
79
|
+
if gate_result.passed:
|
|
80
|
+
return format_success({"gate": gate_type}, message=f"{gate_type} passed")
|
|
81
|
+
return format_error(
|
|
82
|
+
f"{gate_type} failed",
|
|
83
|
+
details=gate_result.issues,
|
|
84
|
+
exit_code=gate_result.exit_code,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def print_and_exit(output: str, exit_code: int = EXIT_OK) -> None:
|
|
89
|
+
"""Print output and exit with given code."""
|
|
90
|
+
print(output)
|
|
91
|
+
sys.exit(exit_code)
|