@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,34 @@
1
+ ---
2
+ name: req-to-plan
3
+ description: Requirement-to-PLAN workflow skill (r2p {{R2P_VERSION}})
4
+ ---
5
+
6
+ # req-to-plan Skill
7
+
8
+ Use this skill to drive the 5-stage requirement-to-PLAN workflow in any project where r2p is installed.
9
+
10
+ ## Available Commands
11
+
12
+ Run each via Bash using the scripts in `{{R2P_BIN_DIR}}`:
13
+
14
+ | Command | Purpose |
15
+ |---|---|
16
+ | `{{R2P_BIN_DIR}}/r2p-start [--separate] ("<requirement>" \| --file <path>)` | Start a new workflow run |
17
+ | `{{R2P_BIN_DIR}}/r2p-continue` | Continue the active run |
18
+ | `{{R2P_BIN_DIR}}/r2p-tier-lock --work-id <id> --base <light\|standard> --confirm` | Lock the tier for a run |
19
+ | `{{R2P_BIN_DIR}}/r2p-status [--all]` | Inspect run state (read-only) |
20
+ | `{{R2P_BIN_DIR}}/r2p-switch --work-id <id>` | Switch active run pointer |
21
+ | `{{R2P_BIN_DIR}}/r2p-reopen --from <work-id> --stage <stage> --reason "<text>"` | Reopen a closed run |
22
+
23
+ ## Usage Pattern
24
+
25
+ 1. `r2p-start ("<requirement>" | --file <path>)` — start a new run
26
+ 2. `r2p-continue` — repeatedly; runs safe automatic steps, stops and suggests the next command when a human action is needed
27
+ - Stops at: tier not locked, needs stage artifact content, needs stage ready mark, needs human checkpoint approval, entry gate failed, needs repair (Quality Gate failed or changes requested)
28
+ - When stopped at `needs_content` or `needs_repair`, write the required artifact content into the printed `content_file`, then run the printed `next:` command exactly
29
+ - For other stops, run the printed `next:` command exactly; if an `alt:` command is shown, use it only when that alternate decision is intended
30
+ - Auto-runs: eligible entry gates, eligible Quality Gates, opens checkpoint review (`review-checkpoint`)
31
+ - Auto-advances: moves to the next stage after non-PLAN checkpoint approval, then runs that stage's entry gate
32
+ - Auto-closes: closes the run when the PLAN checkpoint is approved and no open routes remain
33
+ - Does NOT auto-mark artifacts ready and does NOT auto-approve checkpoints — those are human steps
34
+ 3. When the run closes, hand the approved PLAN at `07-plan.md` directly to your executor — the PLAN is executor-neutral and needs no adaptation step.
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: Continue the active requirement-to-PLAN workflow run
3
+ ---
4
+ Run `{{R2P_BIN_DIR}}/r2p-continue` to resume the currently selected run.
5
+
6
+ Usage: `{{R2P_BIN_DIR}}/r2p-continue`
7
+
8
+ Behavior:
9
+ - Performs safe automatic operations only: eligible entry gates, Quality Gates, opening checkpoint review (`review-checkpoint`), stage advancement after non-PLAN checkpoint approval, and run closure after PLAN checkpoint approval with no open routes
10
+ - Stops when human action is required: stage content generation, marking an artifact ready (`stage-ready`), Quality Gate failure or requested changes (repair), human checkpoint approval (`checkpoint-decide`), entry gate failure
11
+ - Stops with `needs_subagent_review` on forced-review runs (a `migration`/`safety`/`cross_project` modifier at `design`/`spec`/`plan`) when the required review file is missing. You are authorized to run a read-only review subagent yourself for this step — no separate human approval is needed
12
+ - Does NOT auto-mark artifacts ready and does NOT auto-approve checkpoints
13
+
14
+ Call repeatedly until the output says `stop:` with a message indicating why the run paused. For `needs_content` and `needs_repair`, write the required artifact content into the printed `content_file`, then run the printed `next:` command exactly. For `needs_subagent_review`, run a review subagent to audit the stage artifact, write its findings to the printed `review_file`, then resume with `r2p-continue` (you do not need to ask for approval to spawn the review subagent). For other stops, run the printed `next:` command exactly. If an `alt:` command is shown, use it only when that alternate decision is intended. Resume with `r2p-continue` after completing that step.
15
+
16
+ Use `r2p-status` to inspect progress without making changes.
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Route an upstream gap back to an owner stage on an open run
3
+ ---
4
+ Run `{{R2P_BIN_DIR}}/r2p-gap-open` to route a discovered upstream gap on an OPEN run back to the stage that owns the missing decision. Downstream artifacts are marked stale and must be re-derived.
5
+
6
+ Usage: `{{R2P_BIN_DIR}}/r2p-gap-open --work-id <work-id> --owner-stage <stage> --required-action "<text>"`
7
+
8
+ Example: `{{R2P_BIN_DIR}}/r2p-gap-open --work-id WF-20260604-login --owner-stage design --required-action "fixed-window burst flaw"`
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Resolve an open upstream-gap route after the owner stage passes gate-quality
3
+ ---
4
+ Run `{{R2P_BIN_DIR}}/r2p-gap-resolve` after the owner stage has been re-worked, marked `ready`, and passed `gate-quality`. It closes the route so the owner can be re-approved and the downstream re-derived.
5
+
6
+ Usage: `{{R2P_BIN_DIR}}/r2p-gap-resolve --work-id <work-id> --route-id <route-id>`
7
+
8
+ Example: `{{R2P_BIN_DIR}}/r2p-gap-resolve --work-id WF-20260604-login --route-id R-1`
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Reopen a closed workflow run from a specific stage
3
+ ---
4
+ Run `{{R2P_BIN_DIR}}/r2p-reopen` to reopen a run that was closed at the PLAN checkpoint.
5
+
6
+ Usage: `{{R2P_BIN_DIR}}/r2p-reopen --from <work-id> --stage <stage> --reason "<text>"`
7
+
8
+ Example: `{{R2P_BIN_DIR}}/r2p-reopen --from WF-20260527-login-rate-limit --stage spec --reason "spec gap found"`
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Start a new requirement-to-PLAN workflow run
3
+ ---
4
+ Run `{{R2P_BIN_DIR}}/r2p-start` with the requirement as a positional argument.
5
+
6
+ Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] ("<raw requirement>" | --file <path>)`
7
+
8
+ To start from a requirement document, pass `--file <path>` instead of inline text — r2p reads the file contents as the requirement (the path itself is never stored as the requirement): `{{R2P_BIN_DIR}}/r2p-start [--separate] --file ./requirement.md`. `--file` and a positional requirement are mutually exclusive.
9
+
10
+ Use `--separate` to create an independent run when another open run exists.
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Inspect the current or all requirement-to-PLAN workflow runs (read-only)
3
+ ---
4
+ Run `{{R2P_BIN_DIR}}/r2p-status` to inspect the active run state.
5
+
6
+ Usage: `{{R2P_BIN_DIR}}/r2p-status [--all]`
7
+
8
+ Use `--all` to list status for every run in the project.
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Switch the active run pointer to a different workflow run
3
+ ---
4
+ Run `{{R2P_BIN_DIR}}/r2p-switch` to change the selected active run.
5
+
6
+ Usage: `{{R2P_BIN_DIR}}/r2p-switch --work-id <id>`
7
+
8
+ Use `r2p-status --all` to discover available work IDs.
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Lock the complexity tier for an active requirement-to-PLAN workflow run
3
+ ---
4
+ Run `{{R2P_BIN_DIR}}/r2p-tier-lock` with the work ID and selected base tier.
5
+
6
+ Usage: `{{R2P_BIN_DIR}}/r2p-tier-lock --work-id <work-id> --base <light|standard> [--modifiers <modifier,...>] --confirm`
7
+
8
+ Use this when `r2p-continue` stops with `tier_not_locked`.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: r2p-continue
3
+ description: Continue the active requirement-to-PLAN workflow run. Use when the user asks to run r2p-continue, continue r2p, resume the active r2p run, or advance the current requirement-to-PLAN workflow.
4
+ ---
5
+
6
+ # r2p-continue
7
+
8
+ Run `{{R2P_BIN_DIR}}/r2p-continue` to resume the currently selected run.
9
+
10
+ Usage: `{{R2P_BIN_DIR}}/r2p-continue`
11
+
12
+ Call repeatedly until the run reaches the closed state. For `needs_content` and `needs_repair` stops, write the required artifact content into the printed `content_file`, then run the printed `next:` command exactly. For a `needs_subagent_review` stop (forced-review runs: a `migration`/`safety`/`cross_project` modifier at `design`/`spec`/`plan`), run a read-only review subagent to audit the stage artifact yourself — no separate human approval is needed — write its findings to the printed `review_file`, then resume with `r2p-continue`. For other stops, run the printed `next:` command exactly. If an `alt:` command is shown, use it only when that alternate decision is intended. Use `r2p-status` to inspect progress.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: r2p-gap-open
3
+ description: Route an upstream gap on an open requirement-to-PLAN run back to the stage that owns the missing decision. Use when the user asks to run r2p-gap-open or route an upstream gap on an open run.
4
+ ---
5
+
6
+ # r2p-gap-open
7
+
8
+ Run `{{R2P_BIN_DIR}}/r2p-gap-open` to route a discovered upstream gap on an OPEN run back to the stage that owns the missing decision. Downstream artifacts are marked stale and must be re-derived.
9
+
10
+ Usage: `{{R2P_BIN_DIR}}/r2p-gap-open --work-id <work-id> --owner-stage <stage> --required-action "<text>"`
11
+
12
+ Example: `{{R2P_BIN_DIR}}/r2p-gap-open --work-id WF-20260604-login --owner-stage design --required-action "fixed-window burst flaw"`
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: r2p-gap-resolve
3
+ description: Resolve an open upstream-gap route after the owner stage is re-worked and passes gate-quality. Use when the user asks to run r2p-gap-resolve or close a gap route.
4
+ ---
5
+
6
+ # r2p-gap-resolve
7
+
8
+ Run `{{R2P_BIN_DIR}}/r2p-gap-resolve` after the owner stage has been re-worked, marked `ready`, and passed `gate-quality`. It closes the route so the owner can be re-approved and the downstream re-derived.
9
+
10
+ Usage: `{{R2P_BIN_DIR}}/r2p-gap-resolve --work-id <work-id> --route-id <route-id>`
11
+
12
+ Example: `{{R2P_BIN_DIR}}/r2p-gap-resolve --work-id WF-20260604-login --route-id R-1`
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: r2p-reopen
3
+ description: Reopen a closed requirement-to-PLAN workflow run from a specific stage. Use when the user asks to run r2p-reopen or reopen an r2p run because an upstream stage needs repair.
4
+ ---
5
+
6
+ # r2p-reopen
7
+
8
+ Run `{{R2P_BIN_DIR}}/r2p-reopen` to reopen a run that was closed at the PLAN checkpoint.
9
+
10
+ Usage: `{{R2P_BIN_DIR}}/r2p-reopen --from <work-id> --stage <stage> --reason "<text>"`
11
+
12
+ Example: `{{R2P_BIN_DIR}}/r2p-reopen --from WF-20260527-login-rate-limit --stage spec --reason "spec gap found"`
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: r2p-start
3
+ description: Start a new requirement-to-PLAN workflow run from a raw requirement. Use when the user asks to run r2p-start, start r2p, begin the requirement-to-PLAN workflow, or turn a requirement into a PLAN.
4
+ ---
5
+
6
+ # r2p-start
7
+
8
+ Run `{{R2P_BIN_DIR}}/r2p-start` with the requirement as a positional argument.
9
+
10
+ Usage: `{{R2P_BIN_DIR}}/r2p-start [--separate] ("<raw requirement>" | --file <path>)`
11
+
12
+ To start from a requirement document, pass `--file <path>` instead of inline text — r2p reads the file contents as the requirement (the path itself is never stored as the requirement): `{{R2P_BIN_DIR}}/r2p-start [--separate] --file ./requirement.md`. `--file` and a positional requirement are mutually exclusive.
13
+
14
+ Use `--separate` to create an independent run when another open run exists.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: r2p-status
3
+ description: Inspect current or all requirement-to-PLAN workflow runs without writing state. Use when the user asks to run r2p-status, check r2p status, list r2p runs, or inspect the active r2p run.
4
+ ---
5
+
6
+ # r2p-status
7
+
8
+ Run `{{R2P_BIN_DIR}}/r2p-status` to inspect the active run state.
9
+
10
+ Usage: `{{R2P_BIN_DIR}}/r2p-status [--all]`
11
+
12
+ Use `--all` to list status for every run in the project.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: r2p-switch
3
+ description: Switch the active requirement-to-PLAN run pointer to a different work ID. Use when the user asks to run r2p-switch, select an r2p run, or change the active r2p workflow run.
4
+ ---
5
+
6
+ # r2p-switch
7
+
8
+ Run `{{R2P_BIN_DIR}}/r2p-switch` to change the selected active run.
9
+
10
+ Usage: `{{R2P_BIN_DIR}}/r2p-switch --work-id <id>`
11
+
12
+ Use `r2p-status --all` to discover available work IDs.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: r2p-tier-lock
3
+ description: Lock the complexity tier for the active requirement-to-PLAN workflow run. Use when r2p-continue stops with tier_not_locked or asks for r2p-tier-lock.
4
+ ---
5
+
6
+ # r2p-tier-lock
7
+
8
+ Run `{{R2P_BIN_DIR}}/r2p-tier-lock` with the work ID and selected base tier.
9
+
10
+ Usage: `{{R2P_BIN_DIR}}/r2p-tier-lock --work-id <work-id> --base <light|standard> [--modifiers <modifier,...>] --confirm`
11
+
12
+ Use this only after choosing the tier floor shown by the workflow output.
@@ -0,0 +1,4 @@
1
+ name = "r2p-continue"
2
+ description = "Continue the active requirement-to-PLAN workflow run"
3
+ command = "{{R2P_BIN_DIR}}/r2p-continue"
4
+ version = "{{R2P_VERSION}}"
@@ -0,0 +1,4 @@
1
+ name = "r2p-gap-open"
2
+ description = "Route an upstream gap back to an owner stage on an open run"
3
+ command = "{{R2P_BIN_DIR}}/r2p-gap-open"
4
+ version = "{{R2P_VERSION}}"
@@ -0,0 +1,4 @@
1
+ name = "r2p-gap-resolve"
2
+ description = "Resolve an open upstream-gap route after the owner stage passes gate-quality"
3
+ command = "{{R2P_BIN_DIR}}/r2p-gap-resolve"
4
+ version = "{{R2P_VERSION}}"
@@ -0,0 +1,4 @@
1
+ name = "r2p-reopen"
2
+ description = "Reopen a closed workflow run from a specific stage"
3
+ command = "{{R2P_BIN_DIR}}/r2p-reopen"
4
+ version = "{{R2P_VERSION}}"
@@ -0,0 +1,4 @@
1
+ name = "r2p-start"
2
+ description = "Start a new requirement-to-PLAN workflow run"
3
+ command = "{{R2P_BIN_DIR}}/r2p-start"
4
+ version = "{{R2P_VERSION}}"
@@ -0,0 +1,4 @@
1
+ name = "r2p-status"
2
+ description = "Inspect the current or all requirement-to-PLAN workflow runs"
3
+ command = "{{R2P_BIN_DIR}}/r2p-status"
4
+ version = "{{R2P_VERSION}}"
@@ -0,0 +1,4 @@
1
+ name = "r2p-switch"
2
+ description = "Switch the active run pointer to a different workflow run"
3
+ command = "{{R2P_BIN_DIR}}/r2p-switch"
4
+ version = "{{R2P_VERSION}}"
@@ -0,0 +1,4 @@
1
+ name = "r2p-tier-lock"
2
+ description = "Lock the complexity tier for an active requirement-to-PLAN workflow run"
3
+ command = "{{R2P_BIN_DIR}}/r2p-tier-lock"
4
+ version = "{{R2P_VERSION}}"
@@ -0,0 +1,228 @@
1
+ """
2
+ Artifact lifecycle manager for req-to-plan workflow runs.
3
+
4
+ Each stage produces an artifact file in .req-to-plan/<work-id>/.
5
+ Files use YAML frontmatter to track version, status, and timestamps.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ from tools.workflow_cli.models import Stage, STAGE_ARTIFACT_MAP
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Path helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ def artifact_path(run_dir: Path, stage: Stage) -> Path:
21
+ """Return the expected filesystem path for a stage artifact."""
22
+ return run_dir / STAGE_ARTIFACT_MAP[stage]
23
+
24
+
25
+ def artifact_exists(run_dir: Path, stage: Stage) -> bool:
26
+ """Return True if the artifact file for this stage exists."""
27
+ return artifact_path(run_dir, stage).exists()
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Frontmatter helpers
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ def _frontmatter(
36
+ stage: Stage,
37
+ version: int,
38
+ status: str,
39
+ created_at: str,
40
+ updated_at: str,
41
+ ) -> str:
42
+ return (
43
+ f"---\n"
44
+ f"r2p_stage: {stage.value}\n"
45
+ f"r2p_version: {version}\n"
46
+ f"r2p_status: {status}\n"
47
+ f"r2p_created_at: {created_at}\n"
48
+ f"r2p_updated_at: {updated_at}\n"
49
+ f"---\n\n"
50
+ )
51
+
52
+
53
+ def _parse_frontmatter(text: str) -> tuple[dict, str]:
54
+ """Return (frontmatter_dict, body_content).
55
+
56
+ Expects YAML frontmatter delimited by ``---`` lines.
57
+ Returns empty dict and original text when no frontmatter is present.
58
+ """
59
+ if not text.startswith("---\n"):
60
+ return {}, text
61
+ end = text.find("\n---\n", 4)
62
+ if end == -1:
63
+ return {}, text
64
+ fm_text = text[4:end]
65
+ body = text[end + 5:].lstrip("\n")
66
+ fm: dict[str, str] = {}
67
+ for line in fm_text.splitlines():
68
+ if ": " in line:
69
+ key, val = line.split(": ", 1)
70
+ fm[key.strip()] = val.strip()
71
+ return fm, body
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Core read / write functions
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def write_artifact(
80
+ run_dir: Path,
81
+ stage: Stage,
82
+ content: str,
83
+ version: int = 1,
84
+ status: str = "draft",
85
+ ) -> Path:
86
+ """Write (or overwrite) a stage artifact with YAML frontmatter.
87
+
88
+ Preserves the original ``r2p_created_at`` timestamp when the file
89
+ already exists.
90
+
91
+ Returns the path of the written file.
92
+ """
93
+ path = artifact_path(run_dir, stage)
94
+ run_dir.mkdir(parents=True, exist_ok=True)
95
+ now = datetime.now(timezone.utc).isoformat()
96
+ created_at = now
97
+ if path.exists():
98
+ fm, _ = _parse_frontmatter(path.read_text(encoding="utf-8"))
99
+ created_at = fm.get("r2p_created_at", now)
100
+ full_text = _frontmatter(stage, version, status, created_at, now) + content
101
+ path.write_text(full_text, encoding="utf-8")
102
+ return path
103
+
104
+
105
+ def read_artifact(run_dir: Path, stage: Stage) -> str:
106
+ """Return artifact body content (without frontmatter).
107
+
108
+ Raises FileNotFoundError when the artifact does not exist.
109
+ """
110
+ path = artifact_path(run_dir, stage)
111
+ if not path.exists():
112
+ raise FileNotFoundError(f"Artifact not found: {path}")
113
+ _, body = _parse_frontmatter(path.read_text(encoding="utf-8"))
114
+ return body
115
+
116
+
117
+ def get_artifact_version(run_dir: Path, stage: Stage) -> int:
118
+ """Return the current version number from frontmatter, or 0 if absent."""
119
+ path = artifact_path(run_dir, stage)
120
+ if not path.exists():
121
+ return 0
122
+ fm, _ = _parse_frontmatter(path.read_text(encoding="utf-8"))
123
+ try:
124
+ return int(fm.get("r2p_version", 0))
125
+ except (ValueError, TypeError):
126
+ return 0
127
+
128
+
129
+ def get_artifact_status(run_dir: Path, stage: Stage) -> str:
130
+ """Return the current status string from frontmatter, or 'missing' if absent."""
131
+ path = artifact_path(run_dir, stage)
132
+ if not path.exists():
133
+ return "missing"
134
+ fm, _ = _parse_frontmatter(path.read_text(encoding="utf-8"))
135
+ return fm.get("r2p_status", "unknown")
136
+
137
+
138
+ def update_artifact_status(run_dir: Path, stage: Stage, new_status: str) -> None:
139
+ """Rewrite the artifact preserving content and version but updating status.
140
+
141
+ Raises FileNotFoundError when the artifact does not exist.
142
+ """
143
+ path = artifact_path(run_dir, stage)
144
+ if not path.exists():
145
+ raise FileNotFoundError(f"Artifact not found: {path}")
146
+ fm, body = _parse_frontmatter(path.read_text(encoding="utf-8"))
147
+ write_artifact(
148
+ run_dir,
149
+ stage,
150
+ body,
151
+ version=int(fm.get("r2p_version", 1)),
152
+ status=new_status,
153
+ )
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # ArtifactManager class
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ class ArtifactManager:
162
+ """High-level interface for managing stage artifacts in a run directory."""
163
+
164
+ def __init__(self, run_dir: Path) -> None:
165
+ self.run_dir = run_dir
166
+
167
+ def stage_produce(self, stage: Stage, content: str) -> Path:
168
+ """Write the initial version (v1, draft) of a stage artifact.
169
+
170
+ Raises FileExistsError if the artifact already exists at version > 1
171
+ or with a non-draft status. Use stage_update to create a new version.
172
+ """
173
+ version = get_artifact_version(self.run_dir, stage)
174
+ status = get_artifact_status(self.run_dir, stage)
175
+ if version > 1:
176
+ raise FileExistsError(
177
+ f"Artifact for stage {stage.value!r} is already at version {version}. "
178
+ "Use stage_update to create a new version."
179
+ )
180
+ if status not in ("draft", "missing"):
181
+ raise FileExistsError(
182
+ f"Artifact for stage {stage.value!r} has status {status!r}. "
183
+ "Cannot re-produce a non-draft artifact; use stage_update instead."
184
+ )
185
+ return write_artifact(self.run_dir, stage, content, version=1, status="draft")
186
+
187
+ def stage_update(self, stage: Stage, content: str) -> Path:
188
+ """Increment the version and overwrite content, keeping status as draft."""
189
+ current_version = get_artifact_version(self.run_dir, stage)
190
+ return write_artifact(
191
+ self.run_dir,
192
+ stage,
193
+ content,
194
+ version=current_version + 1,
195
+ status="draft",
196
+ )
197
+
198
+ def stage_ready(self, stage: Stage) -> None:
199
+ """Mark a stage artifact as ready (content unchanged)."""
200
+ status = get_artifact_status(self.run_dir, stage)
201
+ if status == "stale":
202
+ raise ValueError(
203
+ f"Artifact for stage {stage.value!r} is stale. "
204
+ "Use stage_update to create a new version before marking it ready."
205
+ )
206
+ update_artifact_status(self.run_dir, stage, "ready")
207
+
208
+ def mark_stale(self, stage: Stage, reason: str, replaced_by: str) -> None:
209
+ """Mark a stage artifact as stale, recording reason and replaced_by in frontmatter."""
210
+ path = artifact_path(self.run_dir, stage)
211
+ if not path.exists():
212
+ raise FileNotFoundError(f"Artifact not found: {path}")
213
+ fm, body = _parse_frontmatter(path.read_text(encoding="utf-8"))
214
+ now = datetime.now(timezone.utc).isoformat()
215
+ created_at = fm.get("r2p_created_at", now)
216
+ version = int(fm.get("r2p_version", 1))
217
+ content = (
218
+ f"---\n"
219
+ f"r2p_stage: {stage.value}\n"
220
+ f"r2p_version: {version}\n"
221
+ f"r2p_status: stale\n"
222
+ f"r2p_created_at: {created_at}\n"
223
+ f"r2p_updated_at: {now}\n"
224
+ f"r2p_stale_reason: {reason}\n"
225
+ f"r2p_replaced_by: {replaced_by}\n"
226
+ f"---\n\n"
227
+ ) + body
228
+ path.write_text(content, encoding="utf-8")