Skip to content

Commit 2e84071

Browse files
Kasper Jungeclaude
authored andcommitted
feat: add prompts as a first-class primitive so users can save, switch between, and reuse task-focused prompts
- Create src/ralphify/prompts.py with discover_prompts(), resolve_prompt_name(), is_prompt_name() - Add `ralph new prompt <name>` scaffold command - Add `ralph prompts list` to show available prompts - Add positional `prompt_name` arg to `ralph run` with priority chain: inline text > positional name > --prompt-file > ralph.toml > root PROMPT.md - Strip frontmatter when reading prompt files in engine - Add prompt_name to RunConfig and RUN_STARTED event - Register prompts in UI primitives API for automatic CRUD - Add prompt_name to RunCreate model and resolve in create_run() - Update CLAUDE.md with prompts.py and PROMPT.md marker reference Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6d4ef16 commit 2e84071

9 files changed

Lines changed: 464 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ Key modules:
2525
- `cli.py` — CLI commands and the main `run()` loop
2626
- `_frontmatter.py` — Primitive discovery and YAML frontmatter parsing
2727
- `resolver.py` — Template placeholder resolution (`{{ contexts.name }}`, `{{ instructions }}`)
28-
- `checks.py`, `contexts.py`, `instructions.py` — The three primitive types
28+
- `prompts.py` — Named prompt discovery and resolution
29+
- `checks.py`, `contexts.py`, `instructions.py` — The other three primitive types
2930

3031
Tests are in `tests/` with one file per module. Docs are in `docs/` using MkDocs with Material theme.
3132

@@ -38,7 +39,7 @@ Tests are in `tests/` with one file per module. Docs are in `docs/` using MkDocs
3839

3940
## Traps
4041

41-
- Primitive marker filenames (`CHECK.md`, `CONTEXT.md`, `INSTRUCTION.md`) are hardcoded in each module's `discover_*()` function AND in scaffold templates in `cli.py`. Change one → update both.
42+
- Primitive marker filenames (`CHECK.md`, `CONTEXT.md`, `INSTRUCTION.md`, `PROMPT.md`) are hardcoded in each module's `discover_*()` function AND in scaffold templates in `cli.py`. Change one → update both.
4243
- `timeout` and `enabled` frontmatter fields have special type coercion in `_frontmatter.py:parse_frontmatter()`. New typed fields need coercion logic added there.
4344
- Both contexts and instructions share `resolver.py:resolve_placeholders()`. Changes affect both.
4445
- Output is truncated to 5000 chars in `_output.py`. This is intentional.

src/ralphify/cli.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ralphify.contexts import discover_contexts
2121
from ralphify.engine import RunConfig, RunState, _format_duration, run_loop
2222
from ralphify.instructions import discover_instructions
23+
from ralphify.prompts import discover_prompts, is_prompt_name, resolve_prompt_name
2324
from ralphify.detector import detect_project
2425

2526
_console = Console(highlight=False)
@@ -30,6 +31,9 @@
3031
new_app = typer.Typer(help="Scaffold new ralph primitives.", invoke_without_command=True)
3132
app.add_typer(new_app, name="new")
3233

34+
prompts_app = typer.Typer(help="Manage prompt primitives.", invoke_without_command=True)
35+
app.add_typer(prompts_app, name="prompts")
36+
3337

3438
@new_app.callback()
3539
def new_callback(ctx: typer.Context) -> None:
@@ -38,6 +42,31 @@ def new_callback(ctx: typer.Context) -> None:
3842
rprint(ctx.get_help())
3943
raise typer.Exit()
4044

45+
46+
@prompts_app.callback()
47+
def prompts_callback(ctx: typer.Context) -> None:
48+
"""Manage prompt primitives."""
49+
if ctx.invoked_subcommand is None:
50+
rprint(ctx.get_help())
51+
raise typer.Exit()
52+
53+
54+
@prompts_app.command("list")
55+
def prompts_list() -> None:
56+
"""List available prompts."""
57+
prompts = discover_prompts()
58+
root_prompt = Path("PROMPT.md")
59+
if not prompts and not root_prompt.exists():
60+
rprint("[dim]No prompts found.[/dim]")
61+
return
62+
if root_prompt.exists():
63+
size = len(root_prompt.read_text())
64+
rprint(f" [cyan]PROMPT.md[/cyan] (root, {size} chars)")
65+
for p in prompts:
66+
icon = "[green]✓[/green]" if p.enabled else "[dim]○[/dim]"
67+
desc = f" {p.description}" if p.description else ""
68+
rprint(f" {icon} {p.name:<18}{desc}")
69+
4170
BANNER_LINES = [
4271
"██████╗░░█████╗░██╗░░░░░██████╗░██╗░░██╗██╗███████╗██╗░░░██╗",
4372
"██╔══██╗██╔══██╗██║░░░░░██╔══██╗██║░░██║██║██╔════╝╚██╗░██╔╝",
@@ -169,6 +198,15 @@ def _load_config() -> dict:
169198
-->
170199
"""
171200

201+
PROMPT_MD_TEMPLATE = """\
202+
---
203+
description: Describe what this prompt does
204+
enabled: true
205+
---
206+
207+
Your prompt content here.
208+
"""
209+
172210
PROMPT_TEMPLATE = """\
173211
# Prompt
174212
@@ -249,6 +287,14 @@ def context(
249287
_scaffold_primitive("contexts", name, "CONTEXT.md", CONTEXT_MD_TEMPLATE)
250288

251289

290+
@new_app.command()
291+
def prompt(
292+
name: str = typer.Argument(help="Name of the new prompt."),
293+
) -> None:
294+
"""Create a new prompt. Prompts are reusable task-focused prompt files you can switch between."""
295+
_scaffold_primitive("prompts", name, "PROMPT.md", PROMPT_MD_TEMPLATE)
296+
297+
252298
@app.command()
253299
def status() -> None:
254300
"""Show current configuration and validate setup."""
@@ -290,6 +336,10 @@ def status() -> None:
290336
_print_primitives_section("Instructions", instructions,
291337
lambda i: (i.content[:50] + "...") if len(i.content) > 50 else i.content)
292338

339+
prompts = discover_prompts()
340+
_print_primitives_section("Prompts", prompts,
341+
lambda p: p.description or "(no description)")
342+
293343
if issues:
294344
rprint("\n[red]Not ready.[/red] Fix the issues above before running.")
295345
raise typer.Exit(1)
@@ -381,6 +431,7 @@ def emit(self, event: Event) -> None:
381431

382432
@app.command()
383433
def run(
434+
prompt_name: Optional[str] = typer.Argument(None, help="Name of a prompt in .ralph/prompts/."),
384435
n: Optional[int] = typer.Option(None, "-n", help="Max number of iterations. Infinite if not set."),
385436
prompt_text: Optional[str] = typer.Option(None, "-p", "--prompt", help="Ad-hoc prompt text. Overrides the prompt file."),
386437
prompt_file: Optional[str] = typer.Option(None, "--prompt-file", "-f", help="Path to prompt file. Overrides ralph.toml."),
@@ -401,7 +452,42 @@ def run(
401452
agent = toml_config["agent"]
402453
command = agent["command"]
403454
args = agent.get("args", [])
404-
prompt_file_path = prompt_file if prompt_file else agent["prompt"]
455+
456+
# Conflict: positional name + --prompt-file
457+
if prompt_name and prompt_file:
458+
rprint("[red]Cannot use both a prompt name and --prompt-file.[/red]")
459+
raise typer.Exit(1)
460+
461+
# Resolve prompt file path using priority chain:
462+
# --prompt (inline text) > positional name > --prompt-file > ralph.toml > root PROMPT.md
463+
resolved_prompt_name: str | None = None
464+
if prompt_text:
465+
# Inline text — no file needed
466+
prompt_file_path = agent.get("prompt", "PROMPT.md")
467+
elif prompt_name:
468+
# Positional arg — look up in .ralph/prompts/
469+
try:
470+
found = resolve_prompt_name(prompt_name)
471+
except ValueError as e:
472+
rprint(f"[red]{e}[/red]")
473+
raise typer.Exit(1)
474+
prompt_file_path = str(found.path / "PROMPT.md")
475+
resolved_prompt_name = found.name
476+
elif prompt_file:
477+
prompt_file_path = prompt_file
478+
else:
479+
# Fall back to ralph.toml agent.prompt — could be a name or a path
480+
toml_prompt = agent.get("prompt", "PROMPT.md")
481+
if is_prompt_name(toml_prompt):
482+
# Try as a prompt name first, fall back to file path
483+
try:
484+
found = resolve_prompt_name(toml_prompt)
485+
prompt_file_path = str(found.path / "PROMPT.md")
486+
resolved_prompt_name = found.name
487+
except ValueError:
488+
prompt_file_path = toml_prompt
489+
else:
490+
prompt_file_path = toml_prompt
405491

406492
prompt_path = Path(prompt_file_path)
407493
if not prompt_text and not prompt_path.exists():
@@ -416,6 +502,7 @@ def run(
416502
args=args,
417503
prompt_file=prompt_file_path,
418504
prompt_text=prompt_text,
505+
prompt_name=resolved_prompt_name,
419506
max_iterations=n,
420507
delay=delay,
421508
timeout=timeout,

src/ralphify/engine.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
run_all_checks,
2424
)
2525
from ralphify.contexts import discover_contexts, resolve_contexts, run_all_contexts
26+
from ralphify._frontmatter import parse_frontmatter
2627
from ralphify.instructions import discover_instructions, resolve_instructions
2728

2829

@@ -46,6 +47,7 @@ class RunConfig:
4647
args: list[str]
4748
prompt_file: str
4849
prompt_text: str | None = None
50+
prompt_name: str | None = None
4951
max_iterations: int | None = None
5052
delay: float = 0
5153
timeout: float | None = None
@@ -160,6 +162,7 @@ def run_loop(
160162
"max_iterations": config.max_iterations,
161163
"timeout": config.timeout,
162164
"delay": config.delay,
165+
"prompt_name": config.prompt_name,
163166
},
164167
))
165168

@@ -227,7 +230,11 @@ def run_loop(
227230
))
228231

229232
# Assemble prompt
230-
prompt = config.prompt_text if config.prompt_text else prompt_path.read_text()
233+
if config.prompt_text:
234+
prompt = config.prompt_text
235+
else:
236+
raw = prompt_path.read_text()
237+
_, prompt = parse_frontmatter(raw)
231238
if enabled_contexts:
232239
context_results = run_all_contexts(enabled_contexts, config.project_root)
233240
prompt = resolve_contexts(prompt, context_results)

src/ralphify/prompts.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Discover and resolve named prompts from ``.ralph/prompts/<name>/PROMPT.md``.
2+
3+
Prompts are reusable task-focused prompt files that users can switch between
4+
(e.g. ``improve-docs``, ``refactor``, ``add-tests``). They follow the same
5+
``.ralph/<kind>/<name>/MARKER.md`` convention as other primitives.
6+
"""
7+
8+
from dataclasses import dataclass
9+
from pathlib import Path
10+
11+
from ralphify._frontmatter import discover_primitives
12+
13+
14+
@dataclass
15+
class Prompt:
16+
"""A named prompt discovered from ``.ralph/prompts/<name>/PROMPT.md``.
17+
18+
The *content* is the body text below the frontmatter.
19+
"""
20+
21+
name: str
22+
path: Path
23+
description: str = ""
24+
enabled: bool = True
25+
content: str = ""
26+
27+
28+
def discover_prompts(root: Path = Path(".")) -> list[Prompt]:
29+
"""Scan ``.ralph/prompts/`` for subdirectories containing ``PROMPT.md``.
30+
31+
Returns prompts in alphabetical order by name.
32+
"""
33+
return [
34+
Prompt(
35+
name=entry.name,
36+
path=entry,
37+
description=frontmatter.get("description", ""),
38+
enabled=frontmatter.get("enabled", True),
39+
content=body,
40+
)
41+
for entry, frontmatter, body in discover_primitives(root, "prompts", "PROMPT.md")
42+
]
43+
44+
45+
def resolve_prompt_name(name: str, root: Path = Path(".")) -> Prompt:
46+
"""Look up a prompt by name. Raises ``ValueError`` if not found."""
47+
for prompt in discover_prompts(root):
48+
if prompt.name == name:
49+
return prompt
50+
available = [p.name for p in discover_prompts(root)]
51+
msg = f"Prompt '{name}' not found."
52+
if available:
53+
msg += f" Available: {', '.join(available)}"
54+
raise ValueError(msg)
55+
56+
57+
def is_prompt_name(value: str) -> bool:
58+
"""Return True if *value* looks like a prompt name (no ``/`` or file extension)."""
59+
return "/" not in value and "." not in value

src/ralphify/ui/api/primitives.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ralphify.checks import discover_checks
1212
from ralphify.contexts import discover_contexts
1313
from ralphify.instructions import discover_instructions
14+
from ralphify.prompts import discover_prompts
1415
from ralphify.ui.models import PrimitiveResponse, PrimitiveUpdate
1516

1617
router = APIRouter()
@@ -20,6 +21,7 @@
2021
"checks": (discover_checks, "CHECK.md"),
2122
"contexts": (discover_contexts, "CONTEXT.md"),
2223
"instructions": (discover_instructions, "INSTRUCTION.md"),
24+
"prompts": (discover_prompts, "PROMPT.md"),
2325
}
2426

2527

src/ralphify/ui/api/runs.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ralphify.engine import RunConfig
99
from ralphify.manager import RunManager
10+
from ralphify.prompts import resolve_prompt_name
1011
from ralphify.ui.models import RunCreate, RunResponse, RunSettingsUpdate
1112

1213
router = APIRouter()
@@ -36,11 +37,22 @@ def _run_response(managed) -> RunResponse:
3637
async def create_run(body: RunCreate) -> RunResponse:
3738
"""Create and start a new run."""
3839
mgr = _get_manager()
40+
prompt_file = body.prompt_file
41+
resolved_name: str | None = None
42+
if body.prompt_name:
43+
root = Path(body.project_dir)
44+
try:
45+
found = resolve_prompt_name(body.prompt_name, root)
46+
except ValueError as e:
47+
raise HTTPException(status_code=400, detail=str(e))
48+
prompt_file = str(found.path / "PROMPT.md")
49+
resolved_name = found.name
3950
config = RunConfig(
4051
command=body.command,
4152
args=body.args,
42-
prompt_file=body.prompt_file,
53+
prompt_file=prompt_file,
4354
prompt_text=body.prompt_text,
55+
prompt_name=resolved_name,
4456
max_iterations=body.max_iterations,
4557
delay=body.delay,
4658
timeout=body.timeout,

src/ralphify/ui/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class RunCreate(BaseModel):
88
args: list[str] = []
99
prompt_file: str = "PROMPT.md"
1010
prompt_text: str | None = None
11+
prompt_name: str | None = None
1112
max_iterations: int | None = None
1213
delay: float = 0
1314
timeout: float | None = None

0 commit comments

Comments
 (0)