@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,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)
|