@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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/README.zh-CN.md +158 -0
  4. package/bin/r2p.js +38 -0
  5. package/docs/req-to-plan-design.md +277 -0
  6. package/package.json +47 -0
  7. package/requirements.txt +1 -0
  8. package/tools/r2p +10 -0
  9. package/tools/r2p-continue +10 -0
  10. package/tools/r2p-gap-open +10 -0
  11. package/tools/r2p-gap-resolve +10 -0
  12. package/tools/r2p-reopen +10 -0
  13. package/tools/r2p-start +10 -0
  14. package/tools/r2p-status +10 -0
  15. package/tools/r2p-switch +10 -0
  16. package/tools/r2p-tier-lock +10 -0
  17. package/tools/workflow_cli/__init__.py +0 -0
  18. package/tools/workflow_cli/__main__.py +5 -0
  19. package/tools/workflow_cli/agent_shortcuts.py +778 -0
  20. package/tools/workflow_cli/agent_templates/claude/SKILL.md +34 -0
  21. package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +16 -0
  22. package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-open.md +8 -0
  23. package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-resolve.md +8 -0
  24. package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +8 -0
  25. package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +10 -0
  26. package/tools/workflow_cli/agent_templates/claude/commands/r2p-status.md +8 -0
  27. package/tools/workflow_cli/agent_templates/claude/commands/r2p-switch.md +8 -0
  28. package/tools/workflow_cli/agent_templates/claude/commands/r2p-tier-lock.md +8 -0
  29. package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +12 -0
  30. package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-open/SKILL.md +12 -0
  31. package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-resolve/SKILL.md +12 -0
  32. package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +12 -0
  33. package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +14 -0
  34. package/tools/workflow_cli/agent_templates/codex/skills/r2p-status/SKILL.md +12 -0
  35. package/tools/workflow_cli/agent_templates/codex/skills/r2p-switch/SKILL.md +12 -0
  36. package/tools/workflow_cli/agent_templates/codex/skills/r2p-tier-lock/SKILL.md +12 -0
  37. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +4 -0
  38. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-open.toml +4 -0
  39. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-resolve.toml +4 -0
  40. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +4 -0
  41. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +4 -0
  42. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-status.toml +4 -0
  43. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-switch.toml +4 -0
  44. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-tier-lock.toml +4 -0
  45. package/tools/workflow_cli/artifact.py +228 -0
  46. package/tools/workflow_cli/cli.py +1779 -0
  47. package/tools/workflow_cli/gates.py +471 -0
  48. package/tools/workflow_cli/install.py +900 -0
  49. package/tools/workflow_cli/install_cli.py +158 -0
  50. package/tools/workflow_cli/link_expander.py +102 -0
  51. package/tools/workflow_cli/models.py +504 -0
  52. package/tools/workflow_cli/output.py +91 -0
  53. package/tools/workflow_cli/repo_baseline.py +137 -0
  54. package/tools/workflow_cli/state.py +621 -0
  55. package/tools/workflow_cli/tier.py +201 -0
  56. package/tools/workflow_cli/tier_keywords.yaml +45 -0
  57. 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)