@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,621 @@
1
+ """
2
+ Run state manager for the req-to-plan workflow CLI.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from tools.workflow_cli.models import (
12
+ ActiveArtifact,
13
+ BundleAuthorization,
14
+ CheckpointRecord,
15
+ OpenRoute,
16
+ ResumeContext,
17
+ RunRecord,
18
+ RunStatus,
19
+ Stage,
20
+ StaleArtifact,
21
+ TierBase,
22
+ TierEstimate,
23
+ TierModifier,
24
+ UserConfirmation,
25
+ WorkId,
26
+ is_transition_allowed,
27
+ )
28
+ from tools.workflow_cli.version import R2P_VERSION
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Factory
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ def create_run_record(work_id: WorkId) -> RunRecord:
37
+ """Create a new RunRecord initialised for run-start."""
38
+ return RunRecord(
39
+ work_id=work_id,
40
+ status=RunStatus.ACTIVE_STAGE_DRAFT,
41
+ current_stage=Stage.RAW_REQUIREMENT,
42
+ r2p_version=R2P_VERSION,
43
+ resume_context=ResumeContext(
44
+ last_completed_operation="start_workflow_run",
45
+ next_allowed_operation="produce_stage_artifact",
46
+ active_item="raw_requirement",
47
+ ),
48
+ )
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Mutations
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ def update_run_status(record: RunRecord, new_status: RunStatus) -> RunRecord:
57
+ """Validate the transition and update status; raises ValueError if invalid."""
58
+ if not is_transition_allowed(record.status, new_status):
59
+ raise ValueError(
60
+ f"Invalid status transition: {record.status.value!r} → {new_status.value!r}"
61
+ )
62
+ record.status = new_status
63
+ return record
64
+
65
+
66
+ def add_checkpoint(
67
+ record: RunRecord,
68
+ stage: Stage,
69
+ artifact: str,
70
+ version: int,
71
+ downstream_authorization: str,
72
+ bundle_id: Optional[str] = None,
73
+ ) -> RunRecord:
74
+ """Append a CheckpointRecord with the current UTC timestamp."""
75
+ cp = CheckpointRecord(
76
+ stage=stage,
77
+ artifact=artifact,
78
+ version=version,
79
+ approved_at=datetime.now(timezone.utc).isoformat(),
80
+ downstream_authorization=downstream_authorization,
81
+ bundle_id=bundle_id,
82
+ )
83
+ record.approved_checkpoints.append(cp)
84
+ return record
85
+
86
+
87
+ def get_active_artifact(record: RunRecord, stage: Stage) -> Optional[ActiveArtifact]:
88
+ """Return the first ActiveArtifact matching *stage*, or None."""
89
+ for aa in record.active_artifacts:
90
+ if aa.stage == stage:
91
+ return aa
92
+ return None
93
+
94
+
95
+ def upsert_active_artifact(
96
+ record: RunRecord,
97
+ stage: Stage,
98
+ artifact: str,
99
+ version: int,
100
+ status: str,
101
+ ) -> RunRecord:
102
+ """Update an existing ActiveArtifact for *stage* or append a new one."""
103
+ for aa in record.active_artifacts:
104
+ if aa.stage == stage:
105
+ aa.artifact = artifact
106
+ aa.version = version
107
+ aa.status = status
108
+ return record
109
+ record.active_artifacts.append(
110
+ ActiveArtifact(stage=stage, artifact=artifact, version=version, status=status)
111
+ )
112
+ return record
113
+
114
+
115
+ def record_stale_artifact(
116
+ record: RunRecord,
117
+ artifact: str,
118
+ reason: str,
119
+ replaced_by: str,
120
+ required_action: str,
121
+ ) -> RunRecord:
122
+ """Append a StaleArtifact entry."""
123
+ record.stale_artifacts.append(
124
+ StaleArtifact(
125
+ artifact=artifact,
126
+ reason=reason,
127
+ replaced_by=replaced_by,
128
+ required_action=required_action,
129
+ )
130
+ )
131
+ return record
132
+
133
+
134
+ def add_open_route(
135
+ record: RunRecord,
136
+ route_id: str,
137
+ from_stage: Stage,
138
+ owner_stage: Stage,
139
+ required_action: str,
140
+ ) -> RunRecord:
141
+ """Append an OpenRoute with status='open'."""
142
+ record.open_routes.append(
143
+ OpenRoute(
144
+ route_id=route_id,
145
+ from_stage=from_stage,
146
+ owner_stage=owner_stage,
147
+ required_action=required_action,
148
+ status="open",
149
+ )
150
+ )
151
+ return record
152
+
153
+
154
+ def close_route(record: RunRecord, route_id: str) -> RunRecord:
155
+ """Set matching route status to 'repaired'."""
156
+ for route in record.open_routes:
157
+ if route.route_id == route_id:
158
+ route.status = "repaired"
159
+ return record
160
+
161
+
162
+ def update_resume_context(
163
+ record: RunRecord,
164
+ last_operation: str = "",
165
+ next_operation: str = "",
166
+ active_item: str = "",
167
+ reread_targets: Optional[list[str]] = None,
168
+ reason: str = "",
169
+ ) -> RunRecord:
170
+ """Update non-empty fields in resume_context."""
171
+ rc = record.resume_context
172
+ if last_operation:
173
+ rc.last_completed_operation = last_operation
174
+ if next_operation:
175
+ rc.next_allowed_operation = next_operation
176
+ if active_item:
177
+ rc.active_item = active_item
178
+ if reread_targets is not None:
179
+ rc.required_reread_targets = reread_targets
180
+ if reason:
181
+ rc.resume_reason = reason
182
+ return record
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Tier serialization helpers
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ def _tier_to_block(tier: Optional[TierEstimate], empty_label: str) -> str:
191
+ if tier is None:
192
+ return empty_label
193
+ mods = ", ".join(sorted(m.value for m in tier.modifiers)) if tier.modifiers else ""
194
+ lines = [f"base: {tier.base.value}", f"modifiers: {mods}"]
195
+ return "\n".join(lines)
196
+
197
+
198
+ def _block_to_tier(text: str, empty_label: str) -> Optional[TierEstimate]:
199
+ text = text.strip()
200
+ if text == empty_label:
201
+ return None
202
+ base_val = None
203
+ mods: list[TierModifier] = []
204
+ for line in text.splitlines():
205
+ line = line.strip()
206
+ if line.startswith("base:"):
207
+ base_val = line[len("base:"):].strip()
208
+ elif line.startswith("modifiers:"):
209
+ mods_str = line[len("modifiers:"):].strip()
210
+ if mods_str:
211
+ for m in mods_str.split(","):
212
+ m = m.strip()
213
+ if m:
214
+ mods.append(TierModifier(m))
215
+ if base_val is None:
216
+ return None
217
+ return TierEstimate(base=TierBase(base_val), modifiers=frozenset(mods))
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Markdown table helpers
222
+ # ---------------------------------------------------------------------------
223
+
224
+
225
+ def _escape_cell(val: str) -> str:
226
+ """Escape backslashes and pipes in a markdown table cell value."""
227
+ return val.replace("\\", "\\\\").replace("|", "\\|")
228
+
229
+
230
+ def _unescape_cell(val: str) -> str:
231
+ """Unescape backslashes and pipes in a parsed markdown table cell."""
232
+ return val.replace("\\|", "|").replace("\\\\", "\\")
233
+
234
+
235
+ def _split_cells(row_text: str) -> list[str]:
236
+ """Split pipe-delimited row, honouring \\| and \\\\ escape sequences."""
237
+ cells: list[str] = []
238
+ current: list[str] = []
239
+ i = 0
240
+ while i < len(row_text):
241
+ if row_text[i] == "\\" and i + 1 < len(row_text) and row_text[i + 1] == "|":
242
+ current.append("|")
243
+ i += 2
244
+ elif row_text[i] == "\\" and i + 1 < len(row_text) and row_text[i + 1] == "\\":
245
+ current.append("\\")
246
+ i += 2
247
+ elif row_text[i] == "|":
248
+ cells.append("".join(current).strip())
249
+ current = []
250
+ i += 1
251
+ else:
252
+ current.append(row_text[i])
253
+ i += 1
254
+ remainder = "".join(current).strip()
255
+ if remainder:
256
+ cells.append(remainder)
257
+ return cells
258
+
259
+
260
+ def _parse_md_table(section_text: str) -> list[dict[str, str]]:
261
+ """Parse a markdown table from section text, respecting \\| escapes."""
262
+ lines = [ln for ln in section_text.splitlines() if ln.strip()]
263
+ if len(lines) < 2:
264
+ return []
265
+ raw_headers = lines[0].strip()
266
+ if not raw_headers.startswith("|"):
267
+ return []
268
+ headers = [h.strip() for h in raw_headers.strip("|").split("|")]
269
+ rows: list[dict[str, str]] = []
270
+ for row_line in lines[2:]:
271
+ row_line = row_line.strip()
272
+ if not row_line or not row_line.startswith("|"):
273
+ continue
274
+ # Strip leading/trailing | then split, respecting \| escapes
275
+ inner = row_line[1:]
276
+ if inner.endswith("|"):
277
+ inner = inner[:-1]
278
+ cells = _split_cells(inner)
279
+ while len(cells) < len(headers):
280
+ cells.append("")
281
+ row = {headers[i]: cells[i] for i in range(len(headers))}
282
+ rows.append(row)
283
+ return rows
284
+
285
+
286
+ def _table(headers: list[str], rows: list[list[str]]) -> str:
287
+ """Produce a markdown table string, escaping | and \\ in cell values."""
288
+ sep = "|" + "|".join("---" for _ in headers) + "|"
289
+ header_row = "| " + " | ".join(headers) + " |"
290
+ lines = [header_row, sep]
291
+ for row in rows:
292
+ escaped = [_escape_cell(str(c)) for c in row]
293
+ lines.append("| " + " | ".join(escaped) + " |")
294
+ return "\n".join(lines)
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Markdown serialization
299
+ # ---------------------------------------------------------------------------
300
+
301
+
302
+ def run_record_to_markdown(record: RunRecord) -> str:
303
+ """Serialise a RunRecord to a markdown string."""
304
+ parts: list[str] = []
305
+
306
+ parts.append(f"# Workflow Run: {record.work_id}")
307
+ parts.append("")
308
+
309
+ parts.append("## Status")
310
+ parts.append(record.status.value)
311
+ parts.append("")
312
+
313
+ parts.append("## Current Stage")
314
+ parts.append(record.current_stage.value)
315
+ parts.append("")
316
+
317
+ parts.append("## r2p Version")
318
+ parts.append(record.r2p_version)
319
+ parts.append("")
320
+
321
+ parts.append("## Tier Lock")
322
+ parts.append(_tier_to_block(record.tier_locked, "unlocked"))
323
+ parts.append("")
324
+
325
+ parts.append("## Tier Estimate")
326
+ parts.append(_tier_to_block(record.tier_estimate, "none"))
327
+ parts.append("")
328
+
329
+ # Approved Checkpoints
330
+ cp_rows = [
331
+ [
332
+ cp.stage.value,
333
+ cp.artifact,
334
+ str(cp.version),
335
+ cp.approved_at,
336
+ cp.downstream_authorization,
337
+ cp.bundle_id or "",
338
+ ]
339
+ for cp in record.approved_checkpoints
340
+ ]
341
+ parts.append("## Approved Checkpoints")
342
+ parts.append(
343
+ _table(
344
+ ["Stage", "Artifact", "Version", "Approved At", "Downstream Authorization", "Bundle ID"],
345
+ cp_rows,
346
+ )
347
+ )
348
+ parts.append("")
349
+
350
+ # Bundle Authorizations
351
+ ba_rows = []
352
+ for ba in record.bundle_authorizations:
353
+ stages_str = ", ".join(sorted(s.value for s in ba.stages))
354
+ consumed_str = ", ".join(
355
+ f"{k}:{v}" for k, v in sorted(ba.consumed_by_stage.items())
356
+ )
357
+ ba_rows.append(
358
+ [ba.bundle_id, stages_str, ba.authorized_at, ba.revoked_at or "", consumed_str]
359
+ )
360
+ parts.append("## Bundle Authorizations")
361
+ parts.append(
362
+ _table(
363
+ ["Bundle ID", "Stages", "Authorized At", "Revoked At", "Consumed Stages"],
364
+ ba_rows,
365
+ )
366
+ )
367
+ parts.append("")
368
+
369
+ # Active Artifacts
370
+ aa_rows = [
371
+ [aa.stage.value, aa.artifact, str(aa.version), aa.status]
372
+ for aa in record.active_artifacts
373
+ ]
374
+ parts.append("## Active Artifacts")
375
+ parts.append(_table(["Stage", "Artifact", "Version", "Status"], aa_rows))
376
+ parts.append("")
377
+
378
+ # Stale / Superseded Artifacts
379
+ sa_rows = [
380
+ [sa.artifact, sa.reason, sa.replaced_by, sa.required_action]
381
+ for sa in record.stale_artifacts
382
+ ]
383
+ parts.append("## Stale / Superseded Artifacts")
384
+ parts.append(_table(["Artifact", "Reason", "Replaced By", "Required Action"], sa_rows))
385
+ parts.append("")
386
+
387
+ # Open Routes
388
+ or_rows = [
389
+ [r.route_id, r.from_stage.value, r.owner_stage.value, r.required_action, r.status]
390
+ for r in record.open_routes
391
+ ]
392
+ parts.append("## Open Routes")
393
+ parts.append(
394
+ _table(["Route ID", "From Stage", "Owner Stage", "Required Action", "Status"], or_rows)
395
+ )
396
+ parts.append("")
397
+
398
+ # User Confirmations
399
+ uc_rows = [
400
+ [uc.confirmation, uc.stage.value, uc.source, uc.recorded_in]
401
+ for uc in record.user_confirmations
402
+ ]
403
+ parts.append("## User Confirmations")
404
+ parts.append(_table(["Confirmation", "Stage", "Source", "Recorded In"], uc_rows))
405
+ parts.append("")
406
+
407
+ # Resume Context
408
+ rc = record.resume_context
409
+ reread_str = ", ".join(rc.required_reread_targets) if rc.required_reread_targets else ""
410
+ rc_rows = [
411
+ ["Last Completed Operation", rc.last_completed_operation],
412
+ ["Next Allowed Operation", rc.next_allowed_operation],
413
+ ["Active Item", rc.active_item],
414
+ ["Required Reread Targets", reread_str],
415
+ ["Resume Reason", rc.resume_reason],
416
+ ]
417
+ parts.append("## Resume Context")
418
+ parts.append(_table(["Field", "Value"], rc_rows))
419
+ parts.append("")
420
+
421
+ # Reopen Lineage
422
+ parts.append("## Reopen Lineage")
423
+ parts.append(record.reopen_lineage if record.reopen_lineage else "(none)")
424
+ parts.append("")
425
+
426
+ return "\n".join(parts)
427
+
428
+
429
+ # ---------------------------------------------------------------------------
430
+ # Markdown parsing
431
+ # ---------------------------------------------------------------------------
432
+
433
+
434
+ def _split_sections(md: str) -> dict[str, str]:
435
+ """Split markdown into a dict of {section_heading: body_text}."""
436
+ sections: dict[str, str] = {}
437
+ current_heading: Optional[str] = None
438
+ body_lines: list[str] = []
439
+
440
+ for line in md.splitlines():
441
+ if line.startswith("## "):
442
+ if current_heading is not None:
443
+ sections[current_heading] = "\n".join(body_lines).strip()
444
+ current_heading = line[3:].strip()
445
+ body_lines = []
446
+ elif line.startswith("# "):
447
+ # Top-level heading — record as special key
448
+ if current_heading is not None:
449
+ sections[current_heading] = "\n".join(body_lines).strip()
450
+ current_heading = "__title__"
451
+ body_lines = [line[2:].strip()]
452
+ else:
453
+ if current_heading is not None:
454
+ body_lines.append(line)
455
+
456
+ if current_heading is not None:
457
+ sections[current_heading] = "\n".join(body_lines).strip()
458
+
459
+ return sections
460
+
461
+
462
+ def parse_run_record(md: str, work_id: WorkId) -> RunRecord:
463
+ """Parse markdown produced by run_record_to_markdown back into a RunRecord."""
464
+ sections = _split_sections(md)
465
+
466
+ status = RunStatus(sections.get("Status", "not_started").strip())
467
+ current_stage = Stage(sections.get("Current Stage", "raw_requirement").strip())
468
+ r2p_version = sections.get("r2p Version", R2P_VERSION).strip()
469
+
470
+ tier_locked = _block_to_tier(sections.get("Tier Lock", "unlocked"), "unlocked")
471
+ tier_estimate = _block_to_tier(sections.get("Tier Estimate", "none"), "none")
472
+
473
+ # Approved Checkpoints
474
+ approved_checkpoints: list[CheckpointRecord] = []
475
+ cp_rows = _parse_md_table(sections.get("Approved Checkpoints", ""))
476
+ for row in cp_rows:
477
+ approved_checkpoints.append(
478
+ CheckpointRecord(
479
+ stage=Stage(row.get("Stage", "")),
480
+ artifact=row.get("Artifact", ""),
481
+ version=int(row.get("Version", "0") or "0"),
482
+ approved_at=row.get("Approved At", ""),
483
+ downstream_authorization=row.get("Downstream Authorization", ""),
484
+ bundle_id=row.get("Bundle ID") or None,
485
+ )
486
+ )
487
+
488
+ # Bundle Authorizations
489
+ bundle_authorizations: list[BundleAuthorization] = []
490
+ for row in _parse_md_table(sections.get("Bundle Authorizations", "")):
491
+ stages_raw = row.get("Stages", "")
492
+ stages: frozenset[Stage] = frozenset(
493
+ Stage(s.strip()) for s in stages_raw.split(",") if s.strip()
494
+ )
495
+ consumed_raw = row.get("Consumed Stages", "")
496
+ consumed_by: dict[str, str] = {}
497
+ if consumed_raw:
498
+ for item in consumed_raw.split(","):
499
+ item = item.strip()
500
+ if ":" in item:
501
+ k, v = item.split(":", 1)
502
+ consumed_by[k.strip()] = v.strip()
503
+ bundle_authorizations.append(
504
+ BundleAuthorization(
505
+ bundle_id=row.get("Bundle ID", ""),
506
+ stages=stages,
507
+ authorized_at=row.get("Authorized At", ""),
508
+ revoked_at=row.get("Revoked At") or None,
509
+ consumed_by_stage=consumed_by,
510
+ )
511
+ )
512
+
513
+ # Active Artifacts
514
+ active_artifacts: list[ActiveArtifact] = []
515
+ for row in _parse_md_table(sections.get("Active Artifacts", "")):
516
+ active_artifacts.append(
517
+ ActiveArtifact(
518
+ stage=Stage(row.get("Stage", "")),
519
+ artifact=row.get("Artifact", ""),
520
+ version=int(row.get("Version", "0") or "0"),
521
+ status=row.get("Status", ""),
522
+ )
523
+ )
524
+
525
+ # Stale / Superseded Artifacts
526
+ stale_artifacts: list[StaleArtifact] = []
527
+ for row in _parse_md_table(sections.get("Stale / Superseded Artifacts", "")):
528
+ stale_artifacts.append(
529
+ StaleArtifact(
530
+ artifact=row.get("Artifact", ""),
531
+ reason=row.get("Reason", ""),
532
+ replaced_by=row.get("Replaced By", ""),
533
+ required_action=row.get("Required Action", ""),
534
+ )
535
+ )
536
+
537
+ # Open Routes
538
+ open_routes: list[OpenRoute] = []
539
+ for row in _parse_md_table(sections.get("Open Routes", "")):
540
+ open_routes.append(
541
+ OpenRoute(
542
+ route_id=row.get("Route ID", ""),
543
+ from_stage=Stage(row.get("From Stage", "")),
544
+ owner_stage=Stage(row.get("Owner Stage", "")),
545
+ required_action=row.get("Required Action", ""),
546
+ status=row.get("Status", "open"),
547
+ )
548
+ )
549
+
550
+ # User Confirmations
551
+ user_confirmations: list[UserConfirmation] = []
552
+ for row in _parse_md_table(sections.get("User Confirmations", "")):
553
+ user_confirmations.append(
554
+ UserConfirmation(
555
+ confirmation=row.get("Confirmation", ""),
556
+ stage=Stage(row.get("Stage", "")),
557
+ source=row.get("Source", ""),
558
+ recorded_in=row.get("Recorded In", ""),
559
+ )
560
+ )
561
+
562
+ # Resume Context
563
+ resume_context = ResumeContext()
564
+ rc_rows = _parse_md_table(sections.get("Resume Context", ""))
565
+ rc_map = {row.get("Field", ""): row.get("Value", "") for row in rc_rows}
566
+ resume_context.last_completed_operation = rc_map.get("Last Completed Operation", "")
567
+ resume_context.next_allowed_operation = rc_map.get("Next Allowed Operation", "")
568
+ resume_context.active_item = rc_map.get("Active Item", "")
569
+ reread_raw = rc_map.get("Required Reread Targets", "")
570
+ resume_context.required_reread_targets = (
571
+ [t.strip() for t in reread_raw.split(",") if t.strip()] if reread_raw else []
572
+ )
573
+ resume_context.resume_reason = rc_map.get("Resume Reason", "")
574
+
575
+ # Reopen Lineage
576
+ lineage_raw = sections.get("Reopen Lineage", "(none)").strip()
577
+ reopen_lineage: Optional[str] = None if lineage_raw == "(none)" else lineage_raw
578
+
579
+ return RunRecord(
580
+ work_id=work_id,
581
+ status=status,
582
+ current_stage=current_stage,
583
+ r2p_version=r2p_version,
584
+ approved_checkpoints=approved_checkpoints,
585
+ bundle_authorizations=bundle_authorizations,
586
+ active_artifacts=active_artifacts,
587
+ stale_artifacts=stale_artifacts,
588
+ open_routes=open_routes,
589
+ user_confirmations=user_confirmations,
590
+ resume_context=resume_context,
591
+ tier_estimate=tier_estimate,
592
+ tier_locked=tier_locked,
593
+ reopen_lineage=reopen_lineage,
594
+ )
595
+
596
+
597
+ # ---------------------------------------------------------------------------
598
+ # RunStateManager
599
+ # ---------------------------------------------------------------------------
600
+
601
+
602
+ class RunStateManager:
603
+ """Persist and load a RunRecord as run.md in run_dir."""
604
+
605
+ def __init__(self, run_dir: Path) -> None:
606
+ self.run_dir = run_dir
607
+ self.run_path = run_dir / "run.md"
608
+
609
+ def save(self, record: RunRecord) -> None:
610
+ self.run_dir.mkdir(parents=True, exist_ok=True)
611
+ self.run_path.write_text(run_record_to_markdown(record), encoding="utf-8")
612
+
613
+ def load(self) -> RunRecord:
614
+ if not self.run_path.exists():
615
+ raise FileNotFoundError(f"run.md not found at {self.run_path}")
616
+ text = self.run_path.read_text(encoding="utf-8")
617
+ match = re.search(r"# Workflow Run: (WF-\S+)", text)
618
+ if not match:
619
+ raise ValueError("Cannot parse work_id from run.md")
620
+ work_id = WorkId(match.group(1))
621
+ return parse_run_record(text, work_id)