Packages

Prompt Runner SDK - packet-first prompt execution for Elixir and CLI workflows with verifier-owned completion, retry, repair, and git-aware repository orchestration.

Current section

Files

Jump to
prompt_runner_sdk lib prompt_runner source directory_source.ex
Raw

lib/prompt_runner/source/directory_source.ex

defmodule PromptRunner.Source.DirectorySource do
@moduledoc """
Source implementation that loads numbered prompt files from a directory.
"""
@behaviour PromptRunner.Source
alias PromptRunner.Prompt
alias PromptRunner.Source.Result
@impl true
def load(dir, _opts) when is_binary(dir) do
files = prompt_files(dir)
inferred_targets = %{}
{prompts, commit_messages, inferred_targets} =
Enum.reduce(files, {[], %{}, inferred_targets}, fn path, {prompts, messages, targets} ->
prompt = build_prompt(path)
messages = maybe_put_commit_message(messages, prompt)
targets = merge_targets(targets, infer_targets(prompt))
{[prompt | prompts], messages, targets}
end)
{:ok,
%Result{
prompts: Enum.reverse(prompts),
commit_messages: commit_messages,
target_repos: targets_from_map(inferred_targets),
source_root: dir,
project_dir: dir
}}
end
defp prompt_files(dir) do
prompt_md =
dir
|> Path.join("*.prompt.md")
|> Path.wildcard()
|> Enum.sort_by(&sort_key/1)
if prompt_md == [] do
dir
|> Path.join("*.md")
|> Path.wildcard()
|> Enum.sort_by(&sort_key/1)
else
prompt_md
end
end
defp build_prompt(path) do
body = File.read!(path)
{metadata, content} = parse_front_matter(body)
heading_name = parse_h1(content)
mission = parse_mission(content)
validation = validation_commands(metadata, content)
{targets, inferred_paths} = parse_targets(metadata, content)
num = normalize_num(metadata["num"] || filename_num(path) || "01")
phase = normalize_integer(metadata["phase"], 1)
sp = normalize_integer(metadata["sp"], 1)
commit_message =
metadata["commit"] ||
auto_commit_message(heading_name || basename_name(path), mission)
metadata = Map.put(metadata, "inferred_target_paths", inferred_paths)
%Prompt{
num: num,
phase: phase,
sp: sp,
name: heading_name || basename_name(path),
file: Path.basename(path),
body: content,
origin: %{type: :file, path: path},
target_repos: targets,
commit_message: commit_message,
validation_commands: validation,
metadata: metadata
}
end
defp parse_front_matter(text) do
case Regex.run(~r/\A---\n(.*?)\n---\n/s, text, capture: :all_but_first) do
[front_matter] ->
metadata = parse_simple_yaml(front_matter)
content = String.replace_prefix(text, "---\n#{front_matter}\n---\n", "")
{metadata, content}
_ ->
{%{}, text}
end
end
defp parse_simple_yaml(text) do
lines = String.split(text, "\n", trim: true)
Enum.reduce(lines, {%{}, nil}, &parse_yaml_line/2)
|> elem(0)
end
defp parse_yaml_line(line, {acc, current_list_key}) do
cond do
String.starts_with?(line, " - ") and current_list_key ->
append_yaml_list_item(acc, current_list_key, line)
String.contains?(line, ":") ->
parse_yaml_key_value(acc, line)
true ->
{acc, current_list_key}
end
end
defp append_yaml_list_item(acc, current_list_key, line) do
item = line |> String.replace_prefix(" - ", "") |> strip_quotes()
updated = Map.update(acc, current_list_key, [item], &(&1 ++ [item]))
{updated, current_list_key}
end
defp parse_yaml_key_value(acc, line) do
[raw_key, raw_value] = String.split(line, ":", parts: 2)
key = String.trim(raw_key)
value = parse_yaml_value(String.trim(raw_value))
{Map.put(acc, key, value), next_list_key(value, key)}
end
defp parse_yaml_value(""), do: []
defp parse_yaml_value(value) do
if String.starts_with?(value, "[") and String.ends_with?(value, "]") do
value
|> String.trim_leading("[")
|> String.trim_trailing("]")
|> String.split(",", trim: true)
|> Enum.map(&(String.trim(&1) |> strip_quotes()))
else
strip_quotes(value)
end
end
defp next_list_key([], key), do: key
defp next_list_key(_value, _key), do: nil
defp strip_quotes(value) do
value
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_trailing("\"")
|> String.trim_leading("'")
|> String.trim_trailing("'")
end
defp parse_h1(text) do
case Regex.run(~r/^#\s+(.+)$/m, text, capture: :all_but_first) do
[name] -> String.trim(name)
_ -> nil
end
end
defp parse_mission(text) do
case Regex.run(~r/^##\s+Mission\s*\n+(.+?)(?:\n##\s|\z)/ms, text, capture: :all_but_first) do
[mission] ->
mission
|> String.trim()
|> String.split("\n\n")
|> List.first()
|> String.trim()
_ ->
nil
end
end
defp parse_validation_commands(text) do
case Regex.run(~r/^##\s+Validation Commands\s*\n+(.+?)(?:\n##\s|\z)/ms, text,
capture: :all_but_first
) do
[commands] ->
commands
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.filter(&String.starts_with?(&1, "-"))
|> Enum.map(fn line ->
line
|> String.trim_leading("-")
|> String.trim()
|> String.trim_leading("`")
|> String.trim_trailing("`")
end)
_ ->
[]
end
end
defp validation_commands(metadata, text) do
case metadata["validation"] do
commands when is_list(commands) and commands != [] ->
Enum.map(commands, &to_string/1)
_ ->
parse_validation_commands(text)
end
end
defp parse_targets(metadata, text) do
if is_list(metadata["targets"]) do
{Enum.map(metadata["targets"], &to_string/1), []}
else
case parse_repository_roots(text) do
[] ->
{nil, []}
repo_roots ->
{Enum.map(repo_roots, &repo_name_for_root/1), repo_roots}
end
end
end
defp parse_repository_roots(text) do
case Regex.run(~r/^##\s+Repository Root\s*\n+(.+?)(?:\n##\s|\z)/ms, text,
capture: :all_but_first
) do
[roots] ->
roots
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.filter(&String.starts_with?(&1, "-"))
|> Enum.map(fn line ->
line
|> String.trim_leading("-")
|> String.trim()
|> String.trim_leading("`")
|> String.trim_trailing("`")
end)
_ ->
[]
end
end
defp infer_targets(%Prompt{metadata: %{"inferred_target_paths" => paths}, target_repos: repos})
when is_list(paths) and is_list(repos) do
Enum.zip(repos, paths)
|> Enum.into(%{}, fn {name, path} -> {name, %{name: name, path: path, default: false}} end)
end
defp infer_targets(_prompt), do: %{}
defp merge_targets(left, right), do: Map.merge(left, right)
defp targets_from_map(map) when map_size(map) == 0, do: nil
defp targets_from_map(map) do
map
|> Map.values()
|> Enum.sort_by(& &1.name)
|> mark_first_default()
end
defp mark_first_default([]), do: []
defp mark_first_default([first | rest]), do: [%{first | default: true} | rest]
defp maybe_put_commit_message(messages, %Prompt{num: num, commit_message: msg})
when is_binary(msg) and msg != "" do
Map.put(messages, {num, nil}, msg)
end
defp maybe_put_commit_message(messages, _prompt), do: messages
defp auto_commit_message(_name, mission) when is_binary(mission) and mission != "" do
first_line = mission |> String.split("\n") |> List.first() |> String.trim()
if String.contains?(String.downcase(first_line), ":") or
String.starts_with?(first_line, "feat"),
do: first_line,
else: "feat: #{first_line}"
end
defp auto_commit_message(name, _mission), do: "feat: #{name}"
defp sort_key(path) do
base = Path.basename(path)
num = filename_num(path) || "9999"
{String.pad_leading(num, 6, "0"), base}
end
defp filename_num(path) do
case Regex.run(~r/^(\d+)/, Path.basename(path), capture: :all_but_first) do
[num] -> num
_ -> nil
end
end
defp basename_name(path) do
path
|> Path.basename()
|> Path.rootname()
|> Path.rootname()
|> String.replace(~r/^\d+[-_]?/, "")
|> String.replace("_", " ")
|> String.replace("-", " ")
|> String.trim()
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp normalize_num(num) when is_integer(num),
do: num |> Integer.to_string() |> String.pad_leading(2, "0")
defp normalize_num(num) when is_binary(num) do
num
|> String.trim()
|> String.to_integer()
|> normalize_num()
end
defp normalize_integer(nil, default), do: default
defp normalize_integer(value, _default) when is_integer(value), do: value
defp normalize_integer(value, default) when is_binary(value) do
case Integer.parse(value) do
{parsed, ""} -> parsed
_ -> default
end
end
defp normalize_integer(_value, default), do: default
defp repo_name_for_root(root) do
root
|> Path.basename()
|> String.replace(~r/[^a-zA-Z0-9_]/, "_")
end
end