diff --git a/apps/repo_management/lib/mix/tasks/phase1.histories.ex b/apps/repo_management/lib/mix/tasks/phase1.histories.ex new file mode 100644 index 0000000..5a5c744 --- /dev/null +++ b/apps/repo_management/lib/mix/tasks/phase1.histories.ex @@ -0,0 +1,157 @@ +defmodule Mix.Tasks.Phase1.Histories do + @shortdoc "Regenerates the committed Phase 1 Continuum histories" + + @moduledoc """ + Regenerates the committed deterministic-core histories. + + mix phase1.histories --write + + This task is intentionally opt-in because the generated files are replay + contracts and should only change alongside a reviewed flow or reducer change. + """ + + use Mix.Task + + alias Stokowski.Domain + + @impl Mix.Task + def run(["--write"]) do + {:ok, _applications} = Application.ensure_all_started(:continuum) + + Enum.each(fixtures(), fn {name, input} -> + Continuum.Test.reset_in_memory!() + + {:ok, run_id} = Continuum.Test.start_synchronous(Stokowski.Flow, input) + + case Continuum.await(run_id, 1_000) do + {:ok, %{state: :completed}} -> + write_history!(run_id, Path.join(history_dir(), name <> ".term")) + + other -> + Mix.raise("could not generate #{name} history: #{inspect(other)}") + end + end) + + Mix.shell().info("Generated #{length(fixtures())} Phase 1 histories") + end + + def run([]) do + Mix.shell().info( + "History files are unchanged; use mix phase1.histories --write to regenerate" + ) + end + + def run(_args), do: Mix.raise("mix phase1.histories accepts only --write") + + defp fixtures do + [ + {"straight-through", %{snapshot: simple_snapshot(), events: [Domain.agent_completed()]}}, + {"approval", + %{snapshot: review_snapshot(), events: [Domain.agent_completed(), Domain.approve()]}}, + {"rework", + %{ + snapshot: review_snapshot(), + events: [ + Domain.agent_completed(), + Domain.rework("fix"), + Domain.agent_completed(), + Domain.approve() + ] + }}, + {"escalation", + %{ + snapshot: review_snapshot(), + events: [ + Domain.agent_completed(), + Domain.rework("one"), + Domain.agent_completed(), + Domain.rework("two"), + Domain.agent_completed(), + Domain.rework("three") + ] + }}, + {"external-terminal", %{snapshot: review_snapshot(), events: [Domain.terminal(:external)]}}, + {"default-eight-phase", default_input()} + ] + end + + defp default_input do + path = Path.join(repository_root(), "apps/stokowski/priv/examples/default/workflow.yaml") + {:ok, snapshot} = Stokowski.Config.load(path) + + events = [ + Domain.agent_completed(), + Domain.approve(), + Domain.agent_completed(), + Domain.approve(), + Domain.agent_completed(), + Domain.approve(), + Domain.agent_completed() + ] + + %{snapshot: snapshot, events: events} + end + + defp simple_snapshot do + %Domain.WorkflowSnapshot{ + workflow: "history", + entry_phase: "work", + graph: %{ + "work" => %Domain.Phase{ + name: "work", + type: :agent, + prompt: "work", + transitions: %{"complete" => "done"} + }, + "done" => %Domain.Phase{name: "done", type: :terminal} + }, + prompts: %{}, + routing: %{}, + schema_version: 1, + config_version: 1, + fingerprint: "sha256:history" + } + end + + defp review_snapshot do + %{ + simple_snapshot() + | graph: %{ + "work" => %Domain.Phase{ + name: "work", + type: :agent, + prompt: "work", + transitions: %{"complete" => "review"} + }, + "review" => %Domain.Phase{ + name: "review", + type: :gate, + transitions: %{"approve" => "done"}, + rework_to: "work", + max_rework: 2 + }, + "done" => %Domain.Phase{name: "done", type: :terminal} + } + } + end + + defp repository_root, do: Path.expand("../../../../..", __DIR__) + defp history_dir, do: Path.join(repository_root(), "apps/stokowski/test/fixtures/histories") + + defp write_history!(run_id, path) do + generated = path <> ".generated" + + try do + Continuum.Test.dump_history!(run_id, generated) + + if File.exists?(path) and + Continuum.Test.load_history!(path) == Continuum.Test.load_history!(generated) do + :ok + else + File.rename!(generated, path) + end + after + File.rm(generated) + end + end +end diff --git a/apps/stokowski/lib/stokowski/config.ex b/apps/stokowski/lib/stokowski/config.ex new file mode 100644 index 0000000..ff96c3b --- /dev/null +++ b/apps/stokowski/lib/stokowski/config.ex @@ -0,0 +1,879 @@ +defmodule Stokowski.Config do + @moduledoc """ + The deterministic configuration boundary. + + `Stokowski.Workflow` remains responsible for lossless YAML parsing. This + module turns that representation into a validated immutable snapshot. The + snapshot contains resolved prompt bodies and only provider-neutral execution + data, so it is safe to journal and its fingerprint does not depend on a + checkout path, environment variable, timestamp, or secret. + """ + + alias Stokowski.Domain.{Phase, Project, WorkflowSnapshot} + + @schema_version 1 + @config_version 1 + @secret_keys ~w(api_key token secret password access_token authorization)a + + defmodule Loader do + @moduledoc "Lossless canonical YAML and legacy WORKFLOW.md loader." + + @spec read(Path.t()) :: {:ok, map(), binary()} | {:error, term()} + def read(path) do + path = Path.expand(path) + + with {:ok, content} <- File.read(path) do + if Path.extname(path) in [".yaml", ".yml"] do + with {:ok, workflow} <- Stokowski.Workflow.read(path), + {:ok, raw} <- Stokowski.Workflow.normalize(workflow) do + {:ok, with_loader_metadata(raw, workflow), ""} + end + else + parse_markdown(content) + end + end + end + + @spec parse(binary()) :: {:ok, map(), binary()} | {:error, term()} + def parse(content) when is_binary(content) do + if String.starts_with?(content, "---") do + parse_markdown(content) + else + with {:ok, workflow} <- Stokowski.Workflow.parse(content), + {:ok, raw} <- Stokowski.Workflow.normalize(workflow) do + {:ok, with_loader_metadata(raw, workflow), ""} + end + end + end + + defp parse_markdown(content) do + case Regex.run(~r/\A---\s*\n(.*?)\n---\s*(.*)\z/s, content, capture: :all_but_first) do + [front_matter, body] -> + with {:ok, workflow} <- Stokowski.Workflow.parse(front_matter), + {:ok, raw} <- Stokowski.Workflow.normalize(workflow) do + {:ok, with_loader_metadata(raw, workflow), String.trim(body)} + end + + _ -> + {:error, + {:legacy_migration_required, + "WORKFLOW.md needs YAML front matter with a deterministic states graph"}} + end + end + + defp with_loader_metadata(raw, workflow) do + %{phase_order: phase_order, workflow_phase_orders: workflow_phase_orders} = + declaration_metadata(workflow) + + raw + |> Map.put("__phase_order__", phase_order) + |> Map.put("__workflow_phase_orders__", workflow_phase_orders) + end + + defp declaration_metadata(%Stokowski.Workflow{documents: [{:mapping, entries}]}) do + direct_states = find_mapping(entries, "states") + workflows = find_mapping(entries, "workflows") + + phase_order = + case direct_states do + {:mapping, state_entries} -> mapping_keys(state_entries) + _ -> bare_state_order(entries, workflows) + end + + workflow_phase_orders = + case workflows do + {:mapping, workflow_entries} -> + Map.new(workflow_entries, fn {name, value} -> + {to_string(name), mapping_keys(find_mapping_value(value, "states"))} + end) + + _ -> + %{} + end + + %{phase_order: phase_order, workflow_phase_orders: workflow_phase_orders} + end + + defp declaration_metadata(_), do: %{phase_order: [], workflow_phase_orders: %{}} + + defp bare_state_order(entries, nil) do + entries + |> Enum.flat_map(fn + {name, {:mapping, state_entries}} -> + if find_mapping(state_entries, "type"), do: [to_string(name)], else: [] + + _ -> + [] + end) + end + + defp bare_state_order(_entries, _workflows), do: [] + + defp mapping_keys({:mapping, entries}), + do: Enum.map(entries, fn {name, _value} -> to_string(name) end) + + defp mapping_keys(_), do: [] + + defp find_mapping_value({:mapping, entries}, wanted), do: find_mapping(entries, wanted) + defp find_mapping_value(_value, _wanted), do: nil + + defp find_mapping([{key, value} | _rest], wanted) when key == wanted, do: value + defp find_mapping([_ | rest], wanted), do: find_mapping(rest, wanted) + defp find_mapping([], _wanted), do: nil + end + + @doc "Load a YAML or legacy `WORKFLOW.md` and build its selected snapshot." + @spec load(Path.t(), keyword()) :: {:ok, WorkflowSnapshot.t()} | {:error, term()} + def load(path, opts \\ []) do + with {:ok, raw, legacy_body} <- Loader.read(path), + {:ok, snapshot} <- + snapshot(raw, Keyword.put(opts, :workflow_dir, Path.dirname(path)), legacy_body), + :ok <- validate(snapshot) do + {:ok, snapshot} + end + end + + @doc "Parse configuration text without requiring a checkout." + @spec from_yaml(binary(), keyword()) :: {:ok, WorkflowSnapshot.t()} | {:error, term()} + def from_yaml(content, opts \\ []) do + with {:ok, raw, legacy_body} <- Loader.parse(content), + {:ok, snapshot} <- snapshot(raw, opts, legacy_body), + :ok <- validate(snapshot) do + {:ok, snapshot} + end + end + + @doc "Build a snapshot from an already normalized string-keyed map." + @spec snapshot(map(), keyword(), binary()) :: {:ok, WorkflowSnapshot.t()} | {:error, term()} + def snapshot(raw, opts \\ [], legacy_body \\ "") when is_map(raw) do + workflow_dir = Keyword.get(opts, :workflow_dir, ".") + project_name = Keyword.get(opts, :project) + labels = Keyword.get(opts, :labels, []) + prompt_contents = Keyword.get(opts, :prompt_contents, %{}) + + with :ok <- legacy_source_valid(raw, legacy_body), + {:ok, project_raw, project_identity} <- select_project(raw, project_name), + {:ok, workflows} <- workflows(raw, project_raw, workflow_dir), + :ok <- legacy_graph_required(workflows, legacy_body), + {:ok, workflow_name, workflow_raw, routing} <- + select_workflow(raw, project_raw, workflows, labels, opts), + {:ok, graph, declared_entry} <- build_graph(workflow_raw), + {:ok, entry_phase} <- + entry_phase(graph, map_get(workflow_raw, "entry_phase", declared_entry)), + {:ok, prompts} <- + resolve_prompts( + raw, + project_raw, + workflow_raw, + graph, + workflow_dir, + prompt_contents, + legacy_body, + Keyword.get(opts, :allow_absolute_paths, false) + ), + graph <- resolved_graph(graph, prompts), + {:ok, snapshot_data} <- + build_snapshot_data( + raw, + project_identity, + workflow_name, + entry_phase, + graph, + prompts, + routing + ) do + fingerprint = fingerprint(snapshot_data) + + {:ok, + %WorkflowSnapshot{ + workflow: workflow_name, + entry_phase: entry_phase, + graph: graph, + prompts: prompts, + routing: routing, + schema_version: snapshot_data.schema_version, + config_version: snapshot_data.config_version, + fingerprint: fingerprint, + project: project_identity, + capabilities: Map.get(snapshot_data, :capabilities, %{}) + }} + end + end + + @doc "Validate a snapshot's graph and immutable data." + @spec validate(WorkflowSnapshot.t()) :: :ok | {:error, [term()]} + def validate(%WorkflowSnapshot{} = snapshot) do + errors = + snapshot.graph + |> Enum.flat_map(fn {name, phase} -> validate_phase(name, phase, snapshot.graph) end) + |> then(&(&1 ++ validate_entry(snapshot))) + |> then(&(&1 ++ validate_reachability(snapshot))) + + if errors == [], do: :ok, else: {:error, errors} + end + + @doc "Return the checkout-independent fingerprint for snapshot content." + @spec fingerprint(map() | WorkflowSnapshot.t()) :: binary() + def fingerprint(%WorkflowSnapshot{} = snapshot), do: snapshot_data(snapshot) |> fingerprint() + + def fingerprint(data) when is_map(data) do + data + |> fingerprint_data() + |> canonicalize() + |> :erlang.term_to_binary([:deterministic]) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + @doc "Resolve the first matching routing rule for normalized issue labels." + @spec route(map(), [binary()]) :: {:ok, binary(), map()} | {:error, term()} + def route(raw, labels \\ []) when is_map(raw) do + routing = + if Map.has_key?(raw, "routing") or Map.has_key?(raw, :routing), + do: map_get(raw, "routing", %{}), + else: raw + + rules = map_get(routing, "rules", []) + + normalized = + labels + |> List.wrap() + |> MapSet.new(&normalize_label/1) + + case Enum.find(rules, fn rule -> + is_map(rule) and + MapSet.member?(normalized, normalize_label(map_get(rule, "label", ""))) + end) do + nil -> + case map_get(routing, "default") do + name when is_binary(name) and name != "" -> + {:ok, name, %{default: true, label: :unavailable}} + + name when is_atom(name) and not is_nil(name) -> + {:ok, Atom.to_string(name), %{default: true, label: :unavailable}} + + _ -> + {:error, :no_workflow_route} + end + + rule -> + {:ok, to_string(map_get(rule, "workflow")), + %{default: false, label: normalize_label(map_get(rule, "label"))}} + end + end + + defp select_project(raw, requested) do + case map_get(raw, "projects") do + projects when is_list(projects) and projects != [] -> + selected = + Enum.find(projects, fn project -> + to_string(map_get(project, "name", "")) == to_string(requested) + end) || + if(is_nil(requested), do: List.first(projects)) + + if is_map(selected) do + name = String.trim(to_string(map_get(selected, "name", ""))) + + if name == "" do + {:error, :project_name_required} + else + id = map_get(selected, "id", name) + {:ok, selected, %Project{id: to_string(id), name: name}} + end + else + {:error, {:unknown_project, requested}} + end + + _ -> + project = map_get(raw, "project", %{}) + name = to_string(map_get(project, "name", map_get(raw, "project_name", "default"))) + {:ok, raw, %Project{id: name, name: name}} + end + end + + defp workflows(raw, project, workflow_dir) do + external = load_external_workflows(workflow_dir) + inline = map_get(project, "workflows", map_get(raw, "workflows", %{})) + states = map_get(project, "states", map_get(raw, "states", %{})) + phase_orders = map_get(raw, "__workflow_phase_orders__", %{}) + + inline = attach_inline_phase_orders(inline, phase_orders) + + workflows = + external + |> Map.merge(if(is_map(inline), do: inline, else: %{})) + |> maybe_put_default(states, project, raw) + + {:ok, workflows} + end + + defp legacy_graph_required(%{} = workflows, "") when map_size(workflows) > 0, do: :ok + defp legacy_graph_required(%{}, ""), do: {:error, :no_workflows} + defp legacy_graph_required(workflows, _body) when map_size(workflows) > 0, do: :ok + + defp legacy_graph_required(_workflows, _body), + do: + {:error, + {:legacy_migration_required, + "legacy WORKFLOW.md has no deterministic phase graph; add states/transitions to canonical YAML"}} + + @doc "Return the actionable diagnostic used when migrating prose-only legacy workflows." + def migration_diagnostic, + do: + {:legacy_migration_required, + "legacy WORKFLOW.md has no deterministic phase graph; add states/transitions to canonical YAML"} + + defp legacy_source_valid(_raw, ""), do: :ok + + defp legacy_source_valid(raw, _body) do + if has_declared_graph?(raw), + do: :ok, + else: + {:error, + {:legacy_migration_required, + "legacy WORKFLOW.md has no deterministic phase graph; add states/transitions to canonical YAML"}} + end + + defp has_declared_graph?(raw) do + direct = map_get(raw, "states", %{}) + inline = map_get(raw, "workflows", %{}) + projects = map_get(raw, "projects", []) + + (is_map(direct) and map_size(direct) > 0) or + (is_map(inline) and map_size(inline) > 0) or + (is_list(projects) and + Enum.any?(projects, fn project -> + is_map(project) and map_size(map_get(project, "states", %{})) > 0 + end)) + end + + defp load_external_workflows(workflow_dir) do + Path.join(workflow_dir, "workflows") + |> Path.expand() + |> case do + directory -> + if File.dir?(directory) do + directory + |> Path.join("*.y*ml") + |> Path.wildcard() + |> Enum.sort() + |> Enum.reduce(%{}, fn path, acc -> + with {:ok, normalized, _body} <- Loader.read(path) do + name = Path.basename(path) |> String.split(".") |> List.first() + Map.put(acc, name, external_workflow(normalized)) + else + _ -> acc + end + end) + else + %{} + end + end + end + + defp maybe_put_default(workflows, states, project, raw) + when is_map(states) and map_size(states) > 0 do + workflow = %{ + "states" => states, + "prompts" => map_get(project, "prompts", map_get(raw, "prompts", %{})), + "__phase_order__" => map_get(raw, "__phase_order__", []) + } + + Map.put_new(workflows, "default", workflow) + end + + defp maybe_put_default(workflows, _states, _project, _raw), do: workflows + + defp select_workflow(raw, project, workflows, labels, opts) do + requested = Keyword.get(opts, :workflow) + + with {:ok, routed, decision} <- + if(requested, + do: {:ok, to_string(requested), %{default: false, label: :explicit}}, + else: route(map_get(project, "routing", map_get(raw, "routing", %{})), labels) + ) do + case Map.get(workflows, routed) do + workflow_raw when is_map(workflow_raw) -> + {:ok, routed, workflow_raw, Map.put(decision, :workflow, routed)} + + nil -> + {:error, {:unknown_workflow, routed}} + end + else + {:error, _} = error -> + if map_size(workflows) == 1 do + [{name, workflow_raw}] = Map.to_list(workflows) + {:ok, name, workflow_raw, %{default: true, label: :unavailable, workflow: name}} + else + error + end + end + end + + defp build_graph(workflow_raw) do + raw_states = + case map_get(workflow_raw, "states") do + states when is_map(states) -> states + _ -> Map.drop(workflow_raw, ["__phase_order__", "__workflow_phase_orders__"]) + end + + phase_order = map_get(workflow_raw, "__phase_order__", []) + ordered_entries = ordered_entries(raw_states, phase_order) + + if not is_map(raw_states) or map_size(raw_states) == 0 do + {:error, :states_required} + else + {graph, first_agent} = + Enum.reduce(ordered_entries, {%{}, nil}, fn {name, raw}, {acc, first_agent} -> + name = to_string(name) + raw = if is_map(raw), do: raw, else: %{} + type = parse_type(map_get(raw, "type", "agent")) + + phase = %Phase{ + name: name, + type: type, + prompt: optional(map_get(raw, "prompt")), + linear_state: optional(map_get(raw, "linear_state")), + runner: optional(map_get(raw, "runner")), + model: optional(map_get(raw, "model")), + effort: optional(map_get(raw, "effort")), + session: parse_session(map_get(raw, "session", "inherit")), + transitions: normalize_transitions(map_get(raw, "transitions", %{})), + rework_to: optional(map_get(raw, "rework_to")), + max_rework: positive_or_unavailable(map_get(raw, "max_rework")) + } + + {Map.put(acc, name, phase), + if(is_nil(first_agent) and type == :agent, do: name, else: first_agent)} + end) + + declared_entry = map_get(workflow_raw, "entry_phase", map_get(workflow_raw, "entry_state")) + entry = declared_entry || inferred_entry(graph, first_agent) + {:ok, graph, entry} + end + end + + defp entry_phase(_graph, name) when is_binary(name) and name != "", do: {:ok, name} + defp entry_phase(_graph, nil), do: {:error, :agent_entry_phase_required} + + defp inferred_entry(graph, first_agent) do + targets = + graph + |> Enum.flat_map(fn {_name, phase} -> Map.values(phase.transitions) end) + |> MapSet.new() + + graph + |> Enum.filter(fn {name, phase} -> + phase.type == :agent and not MapSet.member?(targets, name) + end) + |> Enum.map(&elem(&1, 0)) + |> Enum.sort() + |> List.first() || first_agent + end + + defp resolve_prompts( + raw, + project, + workflow, + graph, + workflow_dir, + provided, + legacy_body, + allow_absolute_paths + ) do + prompts = + map_get(workflow, "prompts", map_get(project, "prompts", map_get(raw, "prompts", %{}))) + + globals = map_get(prompts, "global_prompt", map_get(prompts, "global", [])) + globals = if is_list(globals), do: globals, else: [globals] + + with {:ok, global_bodies} <- + resolve_many(globals, workflow_dir, provided, allow_absolute_paths), + {:ok, phase_bodies} <- + resolve_phase_prompts(graph, workflow_dir, provided, allow_absolute_paths), + {:ok, legacy} <- resolve_legacy(legacy_body, raw, workflow_dir, provided) do + {:ok, %{global: global_bodies, phases: phase_bodies, legacy: legacy}} + end + end + + defp resolve_phase_prompts(graph, workflow_dir, provided, allow_absolute_paths) do + Enum.reduce_while(graph, {:ok, %{}}, fn {name, phase}, {:ok, acc} -> + case phase.prompt do + :unavailable -> + {:cont, {:ok, acc}} + + path -> + case resolve_prompt(path, workflow_dir, provided, allow_absolute_paths) do + {:ok, body} -> {:cont, {:ok, Map.put(acc, name, body)}} + {:error, _} = error -> {:halt, error} + end + end + end) + end + + defp resolved_graph(graph, %{phases: phases}) do + Map.new(graph, fn {name, phase} -> + case Map.fetch(phases, name) do + {:ok, body} -> {name, %{phase | prompt: body}} + :error -> {name, phase} + end + end) + end + + defp resolve_legacy("", _raw, _dir, _provided), do: {:ok, :unavailable} + defp resolve_legacy(body, _raw, _dir, _provided), do: {:ok, body} + + defp resolve_many(paths, workflow_dir, provided, allow_absolute_paths) do + Enum.reduce_while(paths, {:ok, []}, fn path, {:ok, acc} -> + case resolve_prompt(path, workflow_dir, provided, allow_absolute_paths) do + {:ok, body} -> {:cont, {:ok, acc ++ [body]}} + {:error, _} = error -> {:halt, error} + end + end) + end + + defp resolve_prompt(:unavailable, _dir, _provided, _allow_absolute_paths), do: {:ok, ""} + + defp resolve_prompt(path, workflow_dir, provided, allow_absolute_paths) do + path = to_string(path) + + case Map.fetch(provided, path) do + {:ok, body} when is_binary(body) -> + {:ok, body} + + _ -> + candidate = Path.expand(path, workflow_dir) + root = Path.expand(workflow_dir) + relative = Path.relative_to(candidate, root, force: true) + absolute? = Path.type(path) == :absolute + outside? = relative == ".." or String.starts_with?(relative, "../") + + cond do + absolute? and not allow_absolute_paths -> + {:error, {:prompt_path_outside_workflow, path}} + + outside? and not (absolute? and allow_absolute_paths) -> + {:error, {:prompt_path_outside_workflow, path}} + + true -> + case File.read(candidate) do + {:ok, body} -> {:ok, body} + {:error, _} -> {:error, {:prompt_not_found, path}} + end + end + end + end + + defp build_snapshot_data(raw, project, workflow, entry, graph, prompts, routing) do + schema_version = + positive_int(map_get(raw, "schema_version", @schema_version), @schema_version) + + config_version = + positive_int(map_get(raw, "config_version", @config_version), @config_version) + + capabilities = + raw + |> map_get("capabilities", %{}) + |> sanitize_capabilities() + + {:ok, + %{ + schema_version: schema_version, + config_version: config_version, + project: project_data(project), + workflow: workflow, + entry_phase: entry, + graph: graph_data(graph), + prompts: prompts, + routing: routing, + capabilities: capabilities + }} + end + + defp snapshot_data(%WorkflowSnapshot{} = snapshot) do + %{ + schema_version: snapshot.schema_version, + config_version: snapshot.config_version, + project: project_data(snapshot.project), + workflow: snapshot.workflow, + entry_phase: snapshot.entry_phase, + graph: graph_data(snapshot.graph), + prompts: snapshot.prompts, + routing: snapshot.routing, + capabilities: sanitize_capabilities(snapshot.capabilities) + } + end + + defp project_data(%Project{id: id, name: name}), do: %{id: id, name: name} + defp project_data(other), do: other + + defp graph_data(graph) do + graph + |> Enum.map(fn {name, phase} -> + {name, + %{ + name: phase.name, + type: phase.type, + prompt: phase.prompt, + linear_state: phase.linear_state, + runner: phase.runner, + model: phase.model, + effort: phase.effort, + session: phase.session, + transitions: phase.transitions, + rework_to: phase.rework_to, + max_rework: phase.max_rework + }} + end) + |> Map.new() + end + + defp canonicalize(value) when is_map(value) do + value + |> Enum.map(fn {key, val} -> {canonical_key(key), canonicalize(val)} end) + |> Enum.sort_by(fn {key, _value} -> key end) + end + + defp canonicalize(value) when is_list(value), do: Enum.map(value, &canonicalize/1) + defp canonicalize(value), do: value + + defp canonical_key(key) when is_atom(key), do: Atom.to_string(key) + defp canonical_key(key), do: key + + defp sanitize_capabilities(value) when is_map(value) do + value + |> Enum.reject(fn {key, _value} -> + secret_key?(key) + end) + |> Map.new(fn {key, val} -> {key, sanitize_capabilities(val)} end) + end + + defp sanitize_capabilities(value) when is_list(value), + do: Enum.map(value, &sanitize_capabilities/1) + + defp sanitize_capabilities(value), do: value + + defp secret_key?(key) do + key = key |> to_string() |> String.downcase() |> String.replace("-", "_") + + key in Enum.map(@secret_keys, &Atom.to_string/1) or + String.contains?(key, "secret") or String.ends_with?(key, "_token") + end + + defp validate_phase(name, %Phase{} = phase, graph) do + transition_errors = + phase.transitions + |> Enum.flat_map(fn {trigger, target} -> + if Map.has_key?(graph, target), + do: [], + else: [{:unknown_transition_target, name, trigger, target}] + end) + + type_errors = + if phase.type in [:agent, :gate, :terminal], + do: [], + else: [{:invalid_phase_type, name, phase.type}] + + prompt_errors = + cond do + phase.type == :agent and phase.prompt == :unavailable -> + [{:prompt_required, name}] + + phase.type == :agent and is_binary(phase.prompt) and String.trim(phase.prompt) == "" -> + [{:prompt_required, name}] + + true -> + [] + end + + trigger_errors = + case phase.type do + :agent -> + if Map.has_key?(phase.transitions, "complete"), + do: [], + else: [{:completion_transition_required, name}] + + :gate -> + if Map.has_key?(phase.transitions, "approve"), + do: [], + else: [{:approval_transition_required, name}] + + :terminal -> + if phase.transitions == %{}, do: [], else: [{:terminal_must_not_transition, name}] + + _ -> + [] + end + + gate_errors = + cond do + phase.type != :gate -> + [] + + phase.rework_to == :unavailable -> + [{:rework_target_required, name}] + + not Map.has_key?(graph, phase.rework_to) -> + [{:unknown_rework_target, name, phase.rework_to}] + + true -> + [] + end + + transition_errors ++ type_errors ++ prompt_errors ++ trigger_errors ++ gate_errors + end + + defp validate_entry(%WorkflowSnapshot{entry_phase: entry, graph: graph}) do + cond do + not Map.has_key?(graph, entry) -> [{:invalid_entry_phase, entry}] + graph[entry].type != :agent -> [{:entry_phase_must_be_agent, entry}] + true -> [] + end + end + + defp validate_reachability(%WorkflowSnapshot{entry_phase: entry, graph: graph}) do + if Map.has_key?(graph, entry) and not reaches_terminal?(graph, entry, MapSet.new()), + do: [{:terminal_unreachable, entry}], + else: [] + end + + defp reaches_terminal?(graph, name, visited) do + case Map.get(graph, name) do + nil -> + false + + phase -> + cond do + MapSet.member?(visited, name) -> + false + + phase.type == :terminal -> + true + + true -> + visited = MapSet.put(visited, name) + + phase.transitions + |> Map.values() + |> Enum.any?(&reaches_terminal?(graph, &1, visited)) + end + end + end + + defp normalize_transitions(value) when is_map(value), + do: Map.new(value, fn {key, val} -> {to_string(key), to_string(val)} end) + + defp normalize_transitions(_), do: %{} + + defp parse_type(value) do + case to_string(value) do + "agent" -> :agent + "gate" -> :gate + "terminal" -> :terminal + _other -> :invalid + end + end + + defp parse_session(value) do + case to_string(value) do + "fresh" -> :fresh + "handoff" -> :handoff + _ -> :inherit + end + end + + defp optional(nil), do: :unavailable + defp optional(""), do: :unavailable + defp optional(value), do: value + + defp positive_or_unavailable(value) when is_integer(value) and value >= 0, do: value + + defp positive_or_unavailable(value) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int >= 0 -> int + _ -> :unavailable + end + end + + defp positive_or_unavailable(_), do: :unavailable + + defp positive_int(value, _default) when is_integer(value) and value > 0, do: value + + defp positive_int(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, ""} when int > 0 -> int + _ -> default + end + end + + defp positive_int(_value, default), do: default + + defp map_get(map, key, default \\ nil) when is_map(map) do + Map.get(map, key, Map.get(map, maybe_atom(key), default)) + end + + defp maybe_atom(key) when is_atom(key), do: key + + defp maybe_atom(key) when is_binary(key) do + try do + String.to_existing_atom(key) + rescue + ArgumentError -> nil + end + end + + defp maybe_atom(_key), do: nil + + defp normalize_label(label), do: label |> to_string() |> String.trim() |> String.downcase() + + defp ordered_entries(states, order) when is_map(states) and is_list(order) do + ordered = + order + |> Enum.map(&to_string/1) + |> Enum.flat_map(fn name -> + if Map.has_key?(states, name), do: [{name, Map.fetch!(states, name)}], else: [] + end) + + present = MapSet.new(Enum.map(ordered, &elem(&1, 0))) + + ordered ++ + Enum.reject(states, fn {name, _value} -> MapSet.member?(present, to_string(name)) end) + end + + defp ordered_entries(states, _order) when is_map(states), do: Map.to_list(states) + defp ordered_entries(_states, _order), do: [] + + defp attach_inline_phase_orders(inline, orders) when is_map(inline) and is_map(orders) do + Map.new(inline, fn {name, workflow} -> + order = Map.get(orders, to_string(name), []) + + if is_map(workflow), + do: {name, Map.put(workflow, "__phase_order__", order)}, + else: {name, workflow} + end) + end + + defp attach_inline_phase_orders(inline, _orders), do: inline + + defp external_workflow(normalized) do + phase_order = map_get(normalized, "__phase_order__", []) + states = map_get(normalized, "states") + + if is_map(states) do + normalized + |> Map.delete("__workflow_phase_orders__") + |> Map.put("__phase_order__", phase_order) + else + %{ + "states" => Map.drop(normalized, ["__phase_order__", "__workflow_phase_orders__"]), + "__phase_order__" => phase_order + } + end + end + + defp fingerprint_data(data) do + data + |> Map.delete(:routing) + |> Map.delete("routing") + end +end diff --git a/apps/stokowski/lib/stokowski/deterministic.ex b/apps/stokowski/lib/stokowski/deterministic.ex new file mode 100644 index 0000000..d8314cf --- /dev/null +++ b/apps/stokowski/lib/stokowski/deterministic.ex @@ -0,0 +1,12 @@ +defmodule Stokowski.Deterministic do + @moduledoc "Audited deterministic term hashing for the pure reducer boundary." + + @doc "Hash a term with Erlang's deterministic external-term encoding." + @spec hash(term()) :: binary() + def hash(term) do + term + |> :erlang.term_to_binary([:deterministic]) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end +end diff --git a/apps/stokowski/lib/stokowski/domain.ex b/apps/stokowski/lib/stokowski/domain.ex new file mode 100644 index 0000000..d26b1c4 --- /dev/null +++ b/apps/stokowski/lib/stokowski/domain.ex @@ -0,0 +1,345 @@ +defmodule Stokowski.Domain do + @moduledoc """ + Provider-neutral values used by Fantasia's deterministic core. + + Optional provider fields use `:unavailable` (or `{:unavailable, reason}`) + instead of an invented empty value. This keeps a missing tracker capability + distinguishable from a provider that returned an empty value. + """ + + @type unavailable :: :unavailable | {:unavailable, atom() | binary()} + + use Continuum.Pure + + def unavailable(reason \\ :not_provided), do: {:unavailable, reason} + + defmodule Issue do + @moduledoc "A normalized issue received from a tracker." + @enforce_keys [:id, :identifier, :title] + defstruct id: nil, + identifier: nil, + title: nil, + description: :unavailable, + url: :unavailable, + priority: :unavailable, + state: :unavailable, + branch: :unavailable, + project: :unavailable, + labels: [], + comments: [] + + @type t :: %__MODULE__{ + id: binary(), + identifier: binary(), + title: binary(), + description: Stokowski.Domain.unavailable() | binary(), + url: Stokowski.Domain.unavailable() | binary(), + priority: Stokowski.Domain.unavailable() | term(), + state: Stokowski.Domain.unavailable() | binary(), + branch: Stokowski.Domain.unavailable() | binary(), + project: Stokowski.Domain.unavailable() | term(), + labels: [binary()], + comments: [Stokowski.Domain.Comment.t()] + } + end + + defmodule Comment do + @moduledoc "A normalized issue comment with explicit attribution." + @enforce_keys [:body] + defstruct id: :unavailable, + body: "", + created_at: :unavailable, + author: :unavailable + + @type t :: %__MODULE__{ + id: Stokowski.Domain.unavailable() | binary(), + body: binary(), + created_at: Stokowski.Domain.unavailable() | DateTime.t(), + author: Stokowski.Domain.unavailable() | binary() + } + end + + defmodule Project do + @moduledoc "The stable project identity used by an attempt." + @enforce_keys [:id, :name] + defstruct id: nil, name: nil + @type t :: %__MODULE__{id: binary(), name: binary()} + end + + defmodule Phase do + @moduledoc "One node in a normalized workflow graph." + @enforce_keys [:name, :type] + defstruct name: nil, + type: :agent, + prompt: :unavailable, + linear_state: :unavailable, + runner: :unavailable, + model: :unavailable, + effort: :unavailable, + session: :inherit, + transitions: %{}, + rework_to: :unavailable, + max_rework: :unavailable + + @type t :: %__MODULE__{ + name: binary(), + type: :agent | :gate | :terminal, + prompt: Stokowski.Domain.unavailable() | binary(), + linear_state: Stokowski.Domain.unavailable() | binary(), + runner: Stokowski.Domain.unavailable() | binary(), + model: Stokowski.Domain.unavailable() | binary(), + effort: Stokowski.Domain.unavailable() | binary(), + session: :inherit | :handoff | :fresh, + transitions: %{optional(binary()) => binary()}, + rework_to: Stokowski.Domain.unavailable() | binary(), + max_rework: Stokowski.Domain.unavailable() | non_neg_integer() + } + end + + defmodule WorkflowSnapshot do + @moduledoc "Immutable, checkout-independent workflow input to the reducer." + @enforce_keys [ + :workflow, + :entry_phase, + :graph, + :prompts, + :routing, + :schema_version, + :config_version, + :fingerprint + ] + defstruct workflow: nil, + entry_phase: nil, + graph: %{}, + prompts: %{}, + routing: %{}, + schema_version: 1, + config_version: 1, + fingerprint: nil, + project: :unavailable, + capabilities: %{} + + @type t :: %__MODULE__{ + workflow: binary(), + entry_phase: binary(), + graph: %{optional(binary()) => Stokowski.Domain.Phase.t()}, + prompts: %{optional(binary()) => term()}, + routing: map(), + schema_version: pos_integer(), + config_version: pos_integer(), + fingerprint: binary(), + project: Stokowski.Domain.unavailable() | Stokowski.Domain.Project.t(), + capabilities: map() + } + end + + defmodule Attempt do + @moduledoc "Stable identity for one dispatch attempt." + @enforce_keys [:project, :issue, :workflow_fingerprint, :phase, :run, :attempt] + defstruct project: nil, + issue: nil, + workflow_fingerprint: nil, + phase: nil, + run: 1, + attempt: 1 + + @type t :: %__MODULE__{ + project: binary(), + issue: binary(), + workflow_fingerprint: binary(), + phase: binary(), + run: pos_integer(), + attempt: pos_integer() + } + end + + defmodule PhaseState do + @moduledoc "Current reducer state; it contains no provider process state." + @enforce_keys [:phase] + defstruct phase: nil, + run: 1, + attempt: 1, + status: :running, + transitions: [], + feedback: :unavailable, + failure: :unavailable, + rework_counts: %{} + + @type t :: %__MODULE__{ + phase: binary(), + run: pos_integer(), + attempt: pos_integer(), + status: :running | :waiting | :completed | :failed | :cancelled | :escalated, + transitions: [map()], + feedback: Stokowski.Domain.unavailable() | binary(), + failure: Stokowski.Domain.unavailable() | term(), + rework_counts: %{optional(binary()) => non_neg_integer()} + } + end + + defmodule AgentCompleted do + @moduledoc "Typed successful completion signal from an agent runner." + defstruct report: :unavailable, result: :unavailable + @type t :: %__MODULE__{report: term(), result: term()} + end + + defmodule GateDecision do + @moduledoc "Typed decision from a human or external gate." + @enforce_keys [:decision] + defstruct decision: nil, feedback: :unavailable, actor: :unavailable + + @type t :: %__MODULE__{ + decision: :approve | :rework | :escalate, + feedback: term(), + actor: term() + } + end + + defmodule RunnerFailed do + @moduledoc "Typed runner failure; failures never select a graph edge." + @enforce_keys [:failure] + defstruct failure: nil + @type t :: %__MODULE__{failure: Stokowski.Domain.Failure.t() | term()} + end + + defmodule TerminalCompleted do + @moduledoc "Typed external terminal signal." + defstruct reason: :external + @type t :: %__MODULE__{reason: term()} + end + + defmodule Failure do + @moduledoc "Classified, provider-neutral failure." + @enforce_keys [:class, :reason] + defstruct class: nil, reason: nil, retryable: false, detail: :unavailable + + @type t :: %__MODULE__{ + class: :runner | :configuration | :provider | :timeout | :cancelled | atom(), + reason: term(), + retryable: boolean(), + detail: term() + } + end + + defmodule RunnerResult do + @moduledoc "Normalized result crossing the runner boundary." + defstruct status: :complete, output: :unavailable, report: :unavailable, usage: :unavailable + @type t :: %__MODULE__{status: atom(), output: term(), report: term(), usage: term()} + end + + defmodule ReportResult do + @moduledoc "Structured report data after safe decoding." + defstruct verdict: :unavailable, + headline: :unavailable, + summary: :unavailable, + classification: :unavailable, + confidence: :unavailable, + key_points: [], + claims: [], + data_sources: [], + verification: [], + changes: [], + artifacts: [], + risks: [], + assumptions: [], + open_questions: [], + next: :unavailable, + next_steps: [] + + @type t :: %__MODULE__{ + verdict: term(), + headline: term(), + summary: term(), + classification: term(), + confidence: term(), + key_points: list(), + claims: list(), + data_sources: list(), + verification: list(), + changes: list(), + artifacts: list(), + risks: list(), + assumptions: list(), + open_questions: list(), + next: term(), + next_steps: list() + } + end + + defmodule RequiredEffect do + @moduledoc "An ordered effect emitted by the pure reducer." + @enforce_keys [:id, :kind, :attempt] + defstruct id: nil, + kind: nil, + attempt: nil, + from: :unavailable, + to: :unavailable, + data: %{} + + @type t :: %__MODULE__{ + id: binary(), + kind: atom(), + attempt: Stokowski.Domain.Attempt.t(), + from: term(), + to: term(), + data: map() + } + end + + def agent_completed(result \\ :unavailable, report \\ :unavailable), + do: %AgentCompleted{result: result, report: report} + + def approve(feedback \\ :unavailable, actor \\ :unavailable), + do: %GateDecision{decision: :approve, feedback: feedback, actor: actor} + + def rework(feedback \\ :unavailable, actor \\ :unavailable), + do: %GateDecision{decision: :rework, feedback: feedback, actor: actor} + + def escalate(reason \\ :manual, actor \\ :unavailable), + do: %GateDecision{decision: :escalate, feedback: reason, actor: actor} + + def failed(%Failure{} = failure), do: %RunnerFailed{failure: failure} + + def failed(reason), + do: %RunnerFailed{failure: %Failure{class: failure_class(reason), reason: reason}} + + def terminal(reason \\ :external), do: %TerminalCompleted{reason: reason} + + def normalize_event(%AgentCompleted{} = event), do: {:ok, event} + def normalize_event(%GateDecision{} = event), do: {:ok, event} + def normalize_event(%RunnerFailed{} = event), do: {:ok, event} + def normalize_event(%TerminalCompleted{} = event), do: {:ok, event} + + def normalize_event(:complete), do: {:ok, agent_completed()} + def normalize_event(:approve), do: {:ok, approve()} + def normalize_event(:rework), do: {:ok, rework()} + def normalize_event(:escalate), do: {:ok, escalate()} + def normalize_event(:terminal), do: {:ok, terminal()} + + def normalize_event(%{"type" => type} = event) do + case to_string(type) do + "complete" -> {:ok, agent_completed(Map.get(event, "result"), Map.get(event, "report"))} + "approve" -> {:ok, approve(Map.get(event, "feedback"), Map.get(event, "actor"))} + "rework" -> {:ok, rework(Map.get(event, "feedback"), Map.get(event, "actor"))} + "escalate" -> {:ok, escalate(Map.get(event, "reason"), Map.get(event, "actor"))} + "failure" -> {:ok, failed(Map.get(event, "failure", Map.get(event, "reason")))} + "terminal" -> {:ok, terminal(Map.get(event, "reason", :external))} + other -> {:error, {:unknown_event, other}} + end + end + + def normalize_event(%{type: _type} = event), + do: + event + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> normalize_event() + + def normalize_event(other), do: {:error, {:invalid_event, other}} + + @doc "Signal-contract validator for the typed phase event boundary." + def valid_event?(event), do: match?({:ok, _normalized}, normalize_event(event)) + + defp failure_class(:timeout), do: :timeout + defp failure_class(:cancelled), do: :cancelled + defp failure_class(_reason), do: :runner +end diff --git a/apps/stokowski/lib/stokowski/flow.ex b/apps/stokowski/lib/stokowski/flow.ex new file mode 100644 index 0000000..61ac34a --- /dev/null +++ b/apps/stokowski/lib/stokowski/flow.ex @@ -0,0 +1,113 @@ +defmodule Stokowski.Flow do + @moduledoc """ + Generic deterministic Fantasia interpreter. + + The input is captured before interpretation. The flow then asks the pure + phase reducer for effects and sends every effect through one fixed fake + activity boundary. Production adapters are deliberately not referenced. + """ + + use Continuum.Workflow, version: 1, signals: [phase_event: {Stokowski.Domain, :valid_event?}] + + alias Stokowski.Domain + alias Stokowski.Domain.WorkflowSnapshot + + def run(input) do + snapshot = snapshot_from(Map.get(input, :snapshot, Map.get(input, "snapshot", input))) + + journaled = + Continuum.side_effect(fn -> + %{ + snapshot: snapshot, + issue: input_issue(input), + events: input_events(input), + await_signal: await_signal?(input) + } + end) + + snapshot = + snapshot_from(Map.get(journaled, :snapshot, Map.get(journaled, "snapshot", journaled))) + + state = Stokowski.PhaseMachine.initial(snapshot) + issue = Map.get(journaled, :issue, Map.get(journaled, "issue", :unavailable)) + events = Map.get(journaled, :events, Map.get(journaled, "events", [])) + await_signal? = Map.get(journaled, :await_signal, Map.get(journaled, "await_signal", false)) + consume(snapshot, state, events, await_signal?, issue) + end + + defp consume( + snapshot, + %Domain.PhaseState{status: status} = state, + _events, + _await_signal?, + _issue + ) + when status in [:completed, :cancelled, :failed, :escalated], + do: result(snapshot, state) + + defp consume(snapshot, state, [], true, issue) do + event = await(signal(:phase_event)) + consume(snapshot, state, [event], true, issue) + end + + defp consume(snapshot, state, [], _await_signal?, _issue), do: result(snapshot, state) + + defp consume(snapshot, state, [event | rest], await_signal?, issue) do + case Stokowski.PhaseMachine.reduce(snapshot, state, event, + project: project(snapshot), + issue: issue + ) do + {:ok, next_state, effects} -> + Enum.each(effects, fn effect -> + _ = activity(Stokowski.Flow.Activities.dispatch(effect)) + end) + + consume(snapshot, next_state, rest, await_signal?, issue) + + {:error, reason} -> + {:error, reason} + end + end + + defp result(snapshot, state), + do: {:ok, %{fingerprint: snapshot.fingerprint, state: state, history: state.transitions}} + + defp input_events(input), do: Map.get(input, :events, Map.get(input, "events", [])) + + defp input_issue(input), do: Map.get(input, :issue, Map.get(input, "issue", :unavailable)) + + defp await_signal?(input), + do: Map.get(input, :await_signal, Map.get(input, "await_signal", false)) + + defp snapshot_from(%WorkflowSnapshot{} = snapshot), do: snapshot + + defp snapshot_from(snapshot) when is_map(snapshot) do + %WorkflowSnapshot{ + workflow: Map.get(snapshot, :workflow, Map.get(snapshot, "workflow", "default")), + entry_phase: Map.get(snapshot, :entry_phase, Map.get(snapshot, "entry_phase", "")), + graph: Map.get(snapshot, :graph, Map.get(snapshot, "graph", %{})), + prompts: Map.get(snapshot, :prompts, Map.get(snapshot, "prompts", %{})), + routing: Map.get(snapshot, :routing, Map.get(snapshot, "routing", %{})), + schema_version: Map.get(snapshot, :schema_version, Map.get(snapshot, "schema_version", 1)), + config_version: Map.get(snapshot, :config_version, Map.get(snapshot, "config_version", 1)), + fingerprint: Map.get(snapshot, :fingerprint, Map.get(snapshot, "fingerprint", "")), + project: Map.get(snapshot, :project, Map.get(snapshot, "project", :unavailable)), + capabilities: Map.get(snapshot, :capabilities, Map.get(snapshot, "capabilities", %{})) + } + end + + defp project(%WorkflowSnapshot{project: %Domain.Project{name: name}}), do: name + defp project(_), do: :unavailable +end + +defmodule Stokowski.Flow.Activities do + @moduledoc "Fixed fake activity boundary for the Phase 1 flow." + + use Continuum.Activity + + @doc "Return normalized effect data without touching a provider." + def dispatch(effect), do: {:ok, %{effect_id: effect.id, kind: effect.kind}} + + @impl true + def run(effect), do: dispatch(effect) +end diff --git a/apps/stokowski/lib/stokowski/phase_machine.ex b/apps/stokowski/lib/stokowski/phase_machine.ex new file mode 100644 index 0000000..5fda66c --- /dev/null +++ b/apps/stokowski/lib/stokowski/phase_machine.ex @@ -0,0 +1,257 @@ +defmodule Stokowski.PhaseMachine do + @moduledoc "Pure phase reduction for an immutable workflow snapshot." + + use Continuum.Pure + + alias Stokowski.Domain + + @type result :: {:ok, Domain.PhaseState.t(), [Domain.RequiredEffect.t()]} | {:error, term()} + + @doc "Creates the initial state. Run and attempt numbers are one-based." + @spec initial(Domain.WorkflowSnapshot.t()) :: Domain.PhaseState.t() + def initial(%Domain.WorkflowSnapshot{entry_phase: phase}), do: %Domain.PhaseState{phase: phase} + def initial(%{entry_phase: phase}), do: %Domain.PhaseState{phase: phase} + def initial(phase) when is_binary(phase), do: %Domain.PhaseState{phase: phase} + + @doc "Reduces one typed event and returns the next state plus ordered effects." + @spec reduce(Domain.WorkflowSnapshot.t(), Domain.PhaseState.t(), term()) :: result() + def reduce(snapshot, state, raw_event) do + with {:ok, event} <- Domain.normalize_event(raw_event), + {:ok, phase} <- phase(snapshot, state.phase), + :ok <- active(state), + {:ok, next, specs} <- reduce_event(snapshot, phase, state, event) do + {:ok, next, effects(snapshot, state, specs)} + end + end + + @doc false + def apply(snapshot, state, event), do: reduce(snapshot, state, event) + + @doc "Adds stable caller identity to an effect without changing the graph." + def reduce(snapshot, state, event, opts) when is_list(opts) do + snapshot = + snapshot + |> put_routing_issue(Keyword.get(opts, :issue)) + |> put_project(Keyword.get(opts, :project)) + + reduce(snapshot, state, event) + end + + @doc "Returns the stable dispatch effect for the current agent phase." + def dispatch_effect(snapshot, %Domain.PhaseState{} = state) do + effects(snapshot, state, [ + {:dispatch, %{phase: state.phase, run: state.run, attempt: state.attempt}} + ]) + |> List.first() + end + + defp reduce_event(_snapshot, _phase, state, %Domain.TerminalCompleted{reason: reason}) do + {:ok, %{state | status: :cancelled, feedback: reason}, [{:cancel, %{reason: reason}}]} + end + + defp reduce_event(_snapshot, _phase, state, %Domain.RunnerFailed{failure: failure}) do + {:ok, %{state | status: :failed, failure: failure}, [{:report_failure, %{failure: failure}}]} + end + + defp reduce_event(snapshot, phase, state, %Domain.AgentCompleted{}) do + if phase.type == :agent, + do: transition(snapshot, phase, state, :complete), + else: {:error, {:invalid_event, :complete, phase.type}} + end + + defp reduce_event(snapshot, phase, state, %Domain.GateDecision{decision: :approve}) do + if phase.type == :gate, + do: transition(snapshot, phase, state, :approve), + else: {:error, {:invalid_event, :approve, phase.type}} + end + + defp reduce_event(snapshot, phase, state, %Domain.GateDecision{ + decision: :rework, + feedback: feedback + }) do + if phase.type == :gate, + do: rework(snapshot, phase, state, feedback), + else: {:error, {:invalid_event, :rework, phase.type}} + end + + defp reduce_event(_snapshot, %Domain.Phase{type: :gate}, state, %Domain.GateDecision{ + decision: :escalate, + feedback: reason + }) do + {:ok, %{state | status: :escalated, feedback: reason}, [{:escalate, %{reason: reason}}]} + end + + defp reduce_event(_snapshot, phase, _state, %Domain.GateDecision{decision: decision}), + do: {:error, {:invalid_event, decision, phase.type}} + + defp reduce_event(_snapshot, _phase, _state, event), do: {:error, {:invalid_event, event}} + + defp transition(snapshot, phase, state, trigger) do + case Map.get(phase.transitions, to_string(trigger)) do + nil -> + {:error, {:missing_transition, phase.name, trigger}} + + target -> + with {:ok, target_phase} <- phase(snapshot, target) do + next_status = status_for(target_phase) + + next = %{ + state + | phase: target, + attempt: 1, + status: next_status, + transitions: state.transitions ++ [%{from: phase.name, event: trigger, to: target}] + } + + specs = + if target_phase.type == :terminal, + do: [{:complete, %{phase: target}}], + else: [{:dispatch, %{phase: target, run: next.run, attempt: next.attempt}}] + + {:ok, next, specs} + end + end + end + + defp rework(snapshot, phase, state, feedback) do + rework_counts = Map.get(state, :rework_counts, %{}) + rework_count = Map.get(rework_counts, phase.name, 0) + + if rework_limit_reached?(phase.max_rework, rework_count) do + failure = %Domain.Failure{ + class: :configuration, + reason: :max_rework_exceeded, + retryable: false, + detail: %{ + phase: phase.name, + rework_count: rework_count, + max_rework: phase.max_rework, + feedback: feedback + } + } + + {:ok, %{state | status: :escalated, failure: failure, feedback: feedback}, + [{:escalate, %{failure: failure}}]} + else + case phase.rework_to do + :unavailable -> + {:error, {:missing_rework_target, phase.name}} + + target -> + with {:ok, _target_phase} <- phase(snapshot, target) do + next = %{ + state + | phase: target, + run: state.run + 1, + attempt: 1, + status: target_status(snapshot, target), + feedback: feedback, + rework_counts: Map.put(rework_counts, phase.name, rework_count + 1), + transitions: + state.transitions ++ [%{from: phase.name, event: :rework, to: target}] + } + + {:ok, next, + [ + {:rework, %{from: phase.name, to: target, feedback: feedback}}, + {:dispatch, %{phase: target, run: next.run, attempt: next.attempt}} + ]} + end + end + end + end + + defp rework_limit_reached?(:unavailable, _count), do: false + defp rework_limit_reached?(limit, count), do: is_integer(limit) and count >= limit + + defp effects(snapshot, state, specs) do + Enum.with_index(specs, fn {kind, data}, index -> + effect_phase = data[:phase] || data[:to] || state.phase + effect_run = data[:run] || state.run + effect_attempt = data[:attempt] || state.attempt + + attempt = %Domain.Attempt{ + project: project_id(snapshot), + issue: issue_id(snapshot), + workflow_fingerprint: snapshot.fingerprint, + phase: effect_phase, + run: effect_run, + attempt: effect_attempt + } + + id = + {attempt, index, kind, data} + |> Stokowski.Deterministic.hash() + + %Domain.RequiredEffect{ + id: "effect:#{id}", + kind: kind, + attempt: attempt, + from: state.phase, + to: data[:phase] || data[:to] || :unavailable, + data: data + } + end) + end + + defp phase(%Domain.WorkflowSnapshot{graph: graph}, name), do: lookup_phase(graph, name) + defp phase(%{graph: graph}, name), do: lookup_phase(graph, name) + + defp lookup_phase(graph, name) do + case Map.get(graph, name) do + nil -> {:error, {:unknown_phase, name}} + %Domain.Phase{} = phase -> {:ok, phase} + raw -> {:ok, struct!(Domain.Phase, raw)} + end + end + + defp active(%Domain.PhaseState{status: :running}), do: :ok + defp active(%Domain.PhaseState{status: :waiting}), do: :ok + defp active(%{status: :running}), do: :ok + defp active(%{status: :waiting}), do: :ok + defp active(%Domain.PhaseState{status: status}), do: {:error, {:terminal_state, status}} + defp active(%{status: status}), do: {:error, {:terminal_state, status}} + + defp project_id(%Domain.WorkflowSnapshot{project: %Domain.Project{id: id}}), do: id + defp project_id(%{project: project}) when is_binary(project), do: project + defp project_id(_), do: :unavailable + + defp put_project(snapshot, nil), do: snapshot + + defp put_project(%Domain.WorkflowSnapshot{} = snapshot, %Domain.Project{} = project), + do: %{snapshot | project: project} + + defp put_project(%Domain.WorkflowSnapshot{} = snapshot, project) when is_binary(project), + do: %{snapshot | project: %Domain.Project{id: project, name: project}} + + defp put_project(snapshot, _), do: snapshot + + defp put_routing_issue(snapshot, nil), do: snapshot + + defp put_routing_issue(%Domain.WorkflowSnapshot{} = snapshot, issue), + do: %{snapshot | routing: Map.put(snapshot.routing, :issue, issue)} + + defp put_routing_issue(snapshot, _), do: snapshot + + defp issue_id(%Domain.WorkflowSnapshot{routing: routing}), + do: issue_identity(Map.get(routing, :issue, :unavailable)) + + defp issue_id(%{routing: routing}), + do: issue_identity(Map.get(routing, :issue, Map.get(routing, "issue", :unavailable))) + + defp issue_identity(%Domain.Issue{id: id}), do: id + defp issue_identity(%{"id" => id}), do: id + defp issue_identity(%{id: id}), do: id + defp issue_identity(issue), do: issue + + defp status_for(%Domain.Phase{type: :gate}), do: :waiting + defp status_for(%Domain.Phase{type: :terminal}), do: :completed + defp status_for(%Domain.Phase{}), do: :running + + defp target_status(snapshot, target) do + case phase(snapshot, target) do + {:ok, target_phase} -> status_for(target_phase) + _ -> :running + end + end +end diff --git a/apps/stokowski/lib/stokowski/prompt.ex b/apps/stokowski/lib/stokowski/prompt.ex new file mode 100644 index 0000000..c5a0c99 --- /dev/null +++ b/apps/stokowski/lib/stokowski/prompt.ex @@ -0,0 +1,429 @@ +defmodule Stokowski.Prompt do + @moduledoc "Safe, deliberately small prompt renderer and lifecycle assembler." + + use Continuum.Pure + + alias Stokowski.Domain.{Issue, Phase, PhaseState, WorkflowSnapshot} + alias Stokowski.Report + + @report_contract Report.contract() + @machine_marker ~r/", + "", + "## Lifecycle Context", + "", + "- **Issue:** #{value(issue, "identifier")} — #{value(issue, "title")}", + "- **State:** #{value(data, "phase")}", + "- **Run:** #{value(data, "run")}", + "- **Attempt:** #{value(data, "attempt")}", + "- **Workflow:** #{value(data, "workflow")}", + "" + ] + + lines = + if truthy?(Map.get(data, "rework")) do + lines ++ + [ + "### Rework", + "", + "This is a **rework run**.", + "", + "**Feedback:**", + "", + value(data, "rework_feedback"), + "" + ] + else + lines + end + + lines = + case Map.get(data, "comments", "") do + "" -> lines + comments -> lines ++ ["### Recent Activity", "", comments, ""] + end + + transitions = Map.get(data, "available_transitions", %{}) + + lines = + if map_size(transitions) > 0 do + transition_lines = + transitions + |> Enum.sort_by(fn {trigger, _target} -> to_string(trigger) end) + |> Enum.map(fn {trigger, target} -> "- `#{trigger}` → **#{target}**" end) + + lines ++ + ["### Transitions", ""] ++ transition_lines ++ [""] + else + lines + end + + (lines ++ @report_contract) + |> Enum.join("\n") + end + + defp tokenize(template), do: Regex.split(@token, template, include_captures: true) + + defp render_nodes(tokens, context), do: parse_nodes(tokens, context, []) |> elem(0) + + defp parse_nodes([], _context, _stop), do: {[], [], nil} + + defp parse_nodes([token | rest], context, stop) do + case directive(token) do + {:if, expression} -> + {truthy_nodes, after_truthy, marker} = parse_nodes(rest, context, [:else, :endif]) + + {false_nodes, after_false, false_marker} = + case marker do + :else -> + parse_nodes(drop_first(after_truthy), context, [:endif]) + + _ -> + {[], after_truthy, marker} + end + + remaining = + cond do + marker == :endif -> drop_first(after_truthy) + false_marker == :endif -> drop_first(after_false) + true -> after_false + end + + chosen = if truthy?(resolve(expression, context)), do: truthy_nodes, else: false_nodes + {tail, remaining, stop_marker} = parse_nodes(remaining, context, stop) + {chosen ++ tail, remaining, stop_marker} + + {:else, _} -> + if :else in stop do + {[], [token | rest], :else} + else + {tail, remaining, marker} = parse_nodes(rest, context, stop) + {[token | tail], remaining, marker} + end + + {:endif, _} -> + if :endif in stop do + {[], [token | rest], :endif} + else + {tail, remaining, marker} = parse_nodes(rest, context, stop) + {[token | tail], remaining, marker} + end + + :text -> + {tail, remaining, marker} = parse_nodes(rest, context, stop) + {[token | tail], remaining, marker} + + :expression -> + {tail, remaining, marker} = parse_nodes(rest, context, stop) + {[render_expression(String.slice(token, 2..-3//1), context) | tail], remaining, marker} + end + end + + defp drop_first([_ | rest]), do: rest + defp drop_first([]), do: [] + + defp directive(token) do + cond do + String.starts_with?(token, "{{") -> + :expression + + String.starts_with?(token, "{%") -> + expression = + token |> String.trim_leading("{%") |> String.trim_trailing("%}") |> String.trim() + + case String.split(expression, ~r/\s+/, parts: 2) do + ["if", condition] -> {:if, condition} + ["else"] -> {:else, nil} + ["endif"] -> {:endif, nil} + _ -> :text + end + + true -> + :text + end + end + + defp render_expression(expression, context) do + expression + |> String.trim() + |> String.split("|", trim: true) + |> case do + [path | filters] -> + filters + |> Enum.reduce(resolve(String.trim(path), context), fn filter, value -> + apply_filter(String.trim(filter), value) + end) + |> format_value() + + _ -> + "" + end + end + + defp apply_filter("lower", value), do: lower(value) + defp apply_filter("lower()", value), do: lower(value) + defp apply_filter(_unknown, value), do: value + + defp lower(value) when is_list(value), do: Enum.map(value, &lower/1) + defp lower(value) when is_binary(value), do: String.downcase(value) + defp lower(value), do: value + + defp resolve(expression, context) do + expression = String.trim(expression) + + cond do + expression in ["true", "True"] -> + true + + expression in ["false", "False"] -> + false + + expression in ["none", "None", "nil"] -> + nil + + String.starts_with?(expression, "\"") and String.ends_with?(expression, "\"") -> + String.slice(expression, 1..-2//1) + + true -> + expression + |> String.split(".") + |> Enum.reduce(context, fn key, value -> lookup(value, key) end) + end + end + + defp lookup(value, key) when is_map(value), + do: Map.get(value, key, Map.get(value, safe_atom(key), "")) + + defp lookup(_value, _key), do: "" + + defp safe_atom(key) do + String.to_existing_atom(key) + rescue + ArgumentError -> nil + end + + defp format_value(:unavailable), do: "" + defp format_value({:unavailable, _reason}), do: "" + defp format_value(nil), do: "" + defp format_value(true), do: "true" + defp format_value(false), do: "false" + defp format_value(value) when is_list(value), do: Enum.map_join(value, ", ", &format_value/1) + defp format_value(value) when is_map(value), do: inspect(value) + defp format_value(value), do: to_string(value) + + defp issue_map(%_{} = issue), do: issue |> Map.from_struct() |> issue_map() + + defp issue_map(issue) when is_map(issue), + do: Map.new(issue, fn {key, value} -> {to_string(key), display_value(value)} end) + + defp issue_map(_), do: %{} + + defp display_value(:unavailable), do: "" + defp display_value({:unavailable, _}), do: "" + defp display_value(value), do: value + + defp value(map, key), do: map |> Map.get(key, "") |> format_value() + + defp truthy?(value), + do: value not in [nil, false, "", [], %{}, :unavailable, {:unavailable, :not_provided}] + + defp normalize_comments(comments) do + comments + |> Enum.map(fn comment -> + comment = if is_struct(comment), do: Map.from_struct(comment), else: comment + + %{ + body: to_string(Map.get(comment, :body, Map.get(comment, "body", ""))), + author: author_value(comment), + created_at: + comment + |> Map.get( + :created_at, + Map.get( + comment, + :createdAt, + Map.get(comment, "createdAt", Map.get(comment, "created_at", :unavailable)) + ) + ) + |> display_value() + |> format_value() + } + end) + |> Enum.reject(&(&1.body == "" or machine_comment?(&1.body))) + |> Enum.sort_by(&{&1.created_at, &1.author, &1.body}) + end + + defp machine_comment?(body), do: Regex.match?(@machine_marker, body) + + defp author_value(comment) do + direct = Map.get(comment, :author, Map.get(comment, "author", :unavailable)) + + actor = + Map.get(comment, :user, Map.get(comment, "user")) || + Map.get(comment, :botActor, Map.get(comment, "botActor")) || + Map.get(comment, :externalUser, Map.get(comment, "externalUser")) + + value = + if is_map(actor), + do: + Map.get( + actor, + "displayName", + Map.get( + actor, + :displayName, + Map.get(actor, "name", Map.get(actor, :name, :unavailable)) + ) + ), + else: direct + + value + |> display_value() + |> format_value() + |> case do + "" -> "unknown author" + value -> value + end + end + + defp comments_markdown([]), do: "" + + defp comments_markdown(comments) do + Enum.map_join(comments, "\n", fn comment -> + [ + "**", + comment.author, + "**", + if(comment.created_at != "", do: " · " <> comment.created_at, else: ""), + "\n\n", + comment.body + ] + |> List.flatten() + |> Enum.join() + end) + end + + defp truncate(value, max) when is_integer(max) and max >= 0 do + if String.length(value) <= max do + value + else + String.slice(value, 0, max) <> "\n\n[comments truncated]" + end + end + + defp truncate(value, _max), do: value +end diff --git a/apps/stokowski/lib/stokowski/report.ex b/apps/stokowski/lib/stokowski/report.ex new file mode 100644 index 0000000..af5e112 --- /dev/null +++ b/apps/stokowski/lib/stokowski/report.ex @@ -0,0 +1,417 @@ +defmodule Stokowski.Report do + @moduledoc "Safe structured-report codec and deterministic markdown projection." + + alias Stokowski.Domain.ReportResult + + @doc "Decode a report map or JSON document without executing model content." + @spec decode(map() | binary()) :: {:ok, ReportResult.t()} | {:error, term()} + def decode(data) when is_map(data), do: {:ok, project(data)} + + def decode(data) when is_binary(data) do + case Jason.decode(data) do + {:ok, map} when is_map(map) -> {:ok, project(map)} + {:ok, _} -> {:error, :report_mapping_required} + {:error, reason} -> {:error, {:invalid_report_json, reason}} + end + end + + def decode(_), do: {:error, :invalid_report} + + @doc "Convert decoded or raw report data to the durable provider-neutral value." + @spec project(map() | ReportResult.t()) :: ReportResult.t() + def project(%ReportResult{} = report), do: report + + def project(data) when is_map(data) do + %ReportResult{ + verdict: field(data, :verdict), + headline: field(data, :headline), + summary: field(data, :summary), + classification: field(data, :classification), + confidence: field(data, :confidence), + key_points: list_field(data, :key_points), + claims: list_field(data, :claims), + data_sources: list_field(data, :data_sources), + verification: list_field(data, :verification), + changes: list_field(data, :changes), + artifacts: list_field(data, :artifacts), + risks: list_field(data, :risks), + assumptions: list_field(data, :assumptions), + open_questions: list_field(data, :open_questions), + next: field(data, :next), + next_steps: list_field(data, :next_steps) + } + end + + @doc "Render a structured report with its recommendation before evidence." + @spec render(map() | ReportResult.t() | nil, keyword()) :: binary() + def render(data, opts \\ []) + + def render(nil, opts) do + fallback = Keyword.get(opts, :fallback, "") + + [ + "## #{Keyword.get(opts, :state, "state")} — no structured report", + "", + "The agent produced no structured report; the result is unverified.", + "", + fallback + ] + |> Enum.join("\n") + end + + def render(data, opts) do + report = project(data) + lines = recommendation(report) ++ ["", "### Structured report"] + lines = add_scalar(lines, "Summary", report.summary) + lines = add_scalar(lines, "Headline", report.headline) + lines = add_scalar(lines, "Classification", report.classification) + lines = add_scalar(lines, "Confidence", report.confidence) + lines = add_claims(lines, report.claims) + lines = add_evidence_coverage(lines, report.claims) + lines = add_data_sources(lines, report.data_sources) + lines = add_changes(lines, report.changes) + lines = add_verification(lines, report.verification) + lines = add_artifacts(lines, report.artifacts, Keyword.get(opts, :uploaded, %{})) + lines = add_bullets(lines, "Assumptions made", report.assumptions) + lines = add_bullets(lines, "Risks", report.risks) + lines = add_bullets(lines, "Open questions", report.open_questions) + Enum.join(lines ++ footer(opts), "\n") + end + + @doc "The report contract inserted into every lifecycle prompt." + @spec contract() :: [binary()] + def contract do + [ + "### Evidence", + "", + "Write screenshots, recordings, and exported evidence under `$STOKOWSKI_ARTIFACTS`.", + "Do not leave evidence files elsewhere in the repository.", + "", + "### Structured reporting", + "", + "Write `.stokowski/report.json` in the workspace root before finishing.", + "It must be valid JSON with these fields:", + "", + "- `summary`: concise result", + "- `headline`: the single most important result", + "- `classification`: the kind of work performed", + "- `confidence`: honest confidence in the conclusion", + "- `key_points`: the evidence chain behind the recommendation", + "- `claims`: claim, evidence, source, and confidence for each finding", + "- `data_sources`: source and how it was independently verified", + "- `verification`: command, pass/fail result, and detail", + "- `artifacts`: bounded evidence files and captions", + "- `assumptions`: decisions made without direct evidence", + "- `risks`: remaining failure modes", + "- `open_questions`: unresolved questions", + "- `verdict`: the supported recommendation", + "- `next`: what should happen next", + "- `next_steps`: ordered concrete actions", + "", + "Do not omit uncertainty or replace evidence with model confidence." + ] + end + + @doc "Load the report file first, then a fenced JSON block from runner output." + def load(workspace, fallback \\ "") do + path = Path.join(workspace, ".stokowski/report.json") + + with {:ok, body} <- File.read(path), {:ok, report} <- decode(body) do + {:ok, report} + else + _ -> + Regex.scan(~r/```(?:json)?\s*(\{.*?\})\s*```/s, fallback, capture: :all_but_first) + |> Enum.find_value({:error, :report_missing}, fn [json] -> + case Jason.decode(json) do + {:ok, map} when is_map(map) -> + if report_shape?(map), do: {:ok, project(map)} + + _ -> + nil + end + end) + end + end + + @doc "Discard a consumed report without making the operation fail on absence." + def discard(workspace) do + workspace + |> Path.join(".stokowski/report.json") + |> File.rm() + |> case do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp recommendation(%ReportResult{verdict: verdict, next: next, next_steps: steps} = report) do + {icon, heading} = verdict_heading(verdict) + lines = ["> ## #{icon} #{heading}"] + + lines = + if next not in [:unavailable, nil, ""], do: lines ++ [">", "> #{format(next)}"], else: lines + + points = + if report.key_points == [], + do: Enum.map(report.claims, &field_any(&1, :claim)), + else: Enum.map(report.key_points, &clean/1) + + points = Enum.reject(points, &(&1 in [nil, ""])) + + lines = + if points == [], + do: lines, + else: lines ++ [">", "> **Why**"] ++ Enum.map(points, &"> - #{clean(&1)}") + + claims = Enum.filter(report.claims, &is_map/1) + unsourced = Enum.count(claims, &(not (present?(&1, :evidence) and present?(&1, :source)))) + + lines = + if unsourced > 0 do + lines ++ [">", "> ⚠️ #{unsourced} of #{length(claims)} claims unsourced"] + else + lines + end + + confidence = format(report.confidence) |> String.downcase() + + lines = + if confidence != "" do + mark = %{"high" => "●●●", "medium" => "●●○", "low" => "●○○"} |> Map.get(confidence, "") + lines ++ [">", "> confidence #{mark} #{confidence}"] + else + lines + end + + case steps do + [] -> + lines + + values -> + numbered = + values + |> Enum.with_index(1) + |> Enum.map(fn {value, index} -> "> #{index}. #{clean(value)}" end) + + lines ++ [">", "> **Next steps**"] ++ numbered + end + end + + defp verdict_heading(value) do + case value |> format() |> String.downcase() |> String.replace("_", "-") do + "approve" -> {"✅", "Approve"} + "complete" -> {"✅", "Complete"} + "stands-up" -> {"✅", "Stands up"} + "rework" -> {"🔄", "Needs rework"} + "needs-rework" -> {"🔄", "Needs rework"} + "request-changes" -> {"🔄", "Request changes"} + "cannot-verify" -> {"⛔", "Cannot verify"} + "not-reproducible" -> {"⛔", "Not reproducible"} + "blocked" -> {"⛔", "Blocked"} + "" -> {"⚠️", "No verdict"} + other -> {"⚠️", titleize(other)} + end + end + + defp add_scalar(lines, _heading, value) when value in [:unavailable, nil, ""], do: lines + defp add_scalar(lines, heading, value), do: lines ++ ["", "### #{heading}", "", format(value)] + + defp add_claims(lines, []), do: lines + + defp add_claims(lines, claims) do + rows = + claims + |> Enum.filter(&is_map/1) + |> Enum.map(fn claim -> + [ + field_any(claim, :claim), + required_field(claim, :evidence), + required_field(claim, :source), + field_any(claim, :confidence) + ] + |> Enum.map(&table_cell/1) + end) + + if rows == [], + do: lines, + else: + lines ++ + [ + "", + "### Findings", + "", + "| Claim | Evidence | Source | Confidence |", + "| --- | --- | --- | --- |" + ] ++ Enum.map(rows, &("| " <> Enum.join(&1, " | ") <> " |")) + end + + defp add_evidence_coverage(lines, []), do: lines + + defp add_evidence_coverage(lines, claims) do + claims = Enum.filter(claims, &is_map/1) + sourced = Enum.count(claims, &(present?(&1, :evidence) and present?(&1, :source))) + total = length(claims) + warning = if sourced == total, do: "", else: " ⚠️ unsourced claims remain" + + lines ++ + [ + "", + "### Evidence coverage", + "", + "#{sourced}/#{total} claims have both evidence and source.#{warning}" + ] + end + + defp add_data_sources(lines, []), do: lines + + defp add_data_sources(lines, sources) do + rows = + sources + |> Enum.filter(&is_map/1) + |> Enum.map(fn source -> + verification = + if present?(source, :how_verified), + do: field_any(source, :how_verified), + else: "⚠️ not verified" + + [field_any(source, :name), verification] |> Enum.map(&table_cell/1) + end) + + if rows == [], + do: lines, + else: + lines ++ + ["", "### Data sources", "", "| Source | How verified |", "| --- | --- |"] ++ + Enum.map(rows, &("| " <> Enum.join(&1, " | ") <> " |")) + end + + defp add_changes(lines, []), do: lines + defp add_changes(lines, changes), do: add_map_table(lines, "Changes", changes, [:file, :what]) + + defp add_verification(lines, []), do: lines + + defp add_verification(lines, checks) do + rows = + checks + |> Enum.filter(&is_map/1) + |> Enum.map(fn check -> + result = field_any(check, :result) |> format() |> String.downcase() + mark = if result in ["pass", "passed", "ok"], do: "✅", else: "❌" + + [field_any(check, :check), "#{mark} #{result}", field_any(check, :detail)] + |> Enum.map(&table_cell/1) + end) + + if rows == [], + do: lines, + else: + lines ++ + ["", "### Verification", "", "| Check | Result | Detail |", "| --- | --- | --- |"] ++ + Enum.map(rows, &("| " <> Enum.join(&1, " | ") <> " |")) + end + + defp add_artifacts(lines, [], _uploaded), do: lines + + defp add_artifacts(lines, artifacts, uploaded) do + rendered = + artifacts + |> Enum.filter(&is_map/1) + |> Enum.map(fn artifact -> + file = field_any(artifact, :file) |> format() + caption = field_any(artifact, :caption) |> format() + url = Map.get(uploaded, file) + + caption = if caption == "", do: file, else: caption + + if url && String.ends_with?(String.downcase(file), ".png"), + do: "![#{caption}](#{url})", + else: "[#{caption}](#{url || file})" + end) + + if rendered == [], + do: lines, + else: lines ++ ["", "### Evidence", ""] ++ Enum.map(rendered, &("- " <> &1)) + end + + defp add_bullets(lines, _heading, []), do: lines + + defp add_bullets(lines, heading, values), + do: lines ++ ["", "### #{heading}", ""] ++ Enum.map(values, &("- " <> clean(&1))) + + defp add_map_table(lines, heading, values, fields) do + rows = + values + |> Enum.filter(&is_map/1) + |> Enum.map(fn value -> Enum.map(fields, &(field_any(value, &1) |> table_cell())) end) + + if rows == [], + do: lines, + else: + lines ++ + [ + "", + "### #{heading}", + "", + "| " <> Enum.map_join(fields, " | ", &titleize(to_string(&1))) <> " |", + "| " <> Enum.map_join(fields, " | ", fn _ -> "---" end) <> " |" + ] ++ Enum.map(rows, &("| " <> Enum.join(&1, " | ") <> " |")) + end + + defp footer(opts) do + case Keyword.get(opts, :usage) do + %{total_tokens: tokens, cost_usd: cost} -> + [ + "", + "_Usage: #{format_number(tokens)} tokens · $#{:erlang.float_to_binary(cost * 1.0, decimals: 2)}_" + ] + + _ -> + [] + end + end + + defp field(map, key), do: Map.get(map, key, Map.get(map, Atom.to_string(key), :unavailable)) + defp field_any(map, key), do: field(map, key) |> format() + defp list_field(map, key), do: if(is_list(field(map, key)), do: field(map, key), else: []) + defp present?(map, key), do: field(map, key) |> format() |> String.trim() != "" + + defp required_field(map, key) do + if present?(map, key) do + field_any(map, key) + else + case key do + :evidence -> "⚠️ no evidence given" + :source -> "⚠️ unsourced" + _ -> "⚠️ missing" + end + end + end + + defp report_shape?(map), + do: + Enum.any?([:summary, :claims, :verdict, :data_sources, :verification], fn key -> + Map.has_key?(map, key) or Map.has_key?(map, Atom.to_string(key)) + end) + + defp clean(value), do: value |> format() |> String.replace("\n", " ") + defp table_cell(value), do: value |> clean() |> String.replace("|", "\\|") + defp format(:unavailable), do: "" + defp format(nil), do: "" + defp format(value) when is_binary(value), do: value + defp format(value) when is_list(value), do: Enum.map_join(value, ", ", &format/1) + defp format(value) when is_map(value), do: inspect(value) + defp format(value), do: to_string(value) + + defp titleize(value), + do: + value + |> String.replace("_", " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) + + defp format_number(value) when is_integer(value), + do: value |> Integer.to_string() |> String.replace(~r/(?<=\d)(?=(\d{3})+$)/, ",") + + defp format_number(value), do: format(value) +end diff --git a/apps/stokowski/lib/stokowski/tracking.ex b/apps/stokowski/lib/stokowski/tracking.ex index 404fec3..b79bab6 100644 --- a/apps/stokowski/lib/stokowski/tracking.ex +++ b/apps/stokowski/lib/stokowski/tracking.ex @@ -1,32 +1,360 @@ defmodule Stokowski.Tracking do - @moduledoc "Timestamp-ordered compatibility parsing for Stokowski tracking markers." + @moduledoc "Deterministic dual-read tracking codec with Fantasia v1 writers." - @marker ~r//s + @marker ~r//s + @machine_marker ~r/" + + defp take_limit(values, :infinity), do: values + + defp take_limit(values, limit) when is_integer(limit) and limit >= 0, + do: Enum.take(values, limit) + + defp take_limit(values, _), do: values end diff --git a/apps/stokowski/lib/stokowski/workflow.ex b/apps/stokowski/lib/stokowski/workflow.ex index 0627f8b..b35d1b2 100644 --- a/apps/stokowski/lib/stokowski/workflow.ex +++ b/apps/stokowski/lib/stokowski/workflow.ex @@ -14,7 +14,15 @@ defmodule Stokowski.Workflow do @spec read(Path.t()) :: {:ok, t()} | {:error, term()} def read(path) do with {:ok, yaml} <- File.read(path), - {:ok, _documents} <- YamlElixir.read_all_from_string(yaml, maps_as_keywords: true), + {:ok, workflow} <- parse(yaml) do + {:ok, workflow} + end + end + + @doc "Parse YAML from a string while retaining Phase 0's lossless shape." + @spec parse(binary()) :: {:ok, t()} | {:error, term()} + def parse(yaml) when is_binary(yaml) do + with {:ok, _documents} <- YamlElixir.read_all_from_string(yaml, maps_as_keywords: true), {:ok, documents} <- parse_documents(yaml) do invalid_flow_aliases = find_flow_collection_aliases(yaml) diff --git a/apps/stokowski/priv/examples/bug-fix/prompts/global.md b/apps/stokowski/priv/examples/bug-fix/prompts/global.md new file mode 100644 index 0000000..bc7f3ac --- /dev/null +++ b/apps/stokowski/priv/examples/bug-fix/prompts/global.md @@ -0,0 +1,3 @@ +# Fantasia bug-fix workflow + +Reproduce the defect before diagnosing or changing it. diff --git a/apps/stokowski/priv/examples/bug-fix/prompts/phase.md b/apps/stokowski/priv/examples/bug-fix/prompts/phase.md new file mode 100644 index 0000000..ca570a8 --- /dev/null +++ b/apps/stokowski/priv/examples/bug-fix/prompts/phase.md @@ -0,0 +1 @@ +Current bug-fix phase for {{ issue.identifier }}: {{ issue.title }}. diff --git a/apps/stokowski/priv/examples/bug-fix/prompts/specialized.md b/apps/stokowski/priv/examples/bug-fix/prompts/specialized.md new file mode 100644 index 0000000..db33844 --- /dev/null +++ b/apps/stokowski/priv/examples/bug-fix/prompts/specialized.md @@ -0,0 +1 @@ +Record exact reproduction and verification evidence. diff --git a/apps/stokowski/priv/examples/bug-fix/workflow.yaml b/apps/stokowski/priv/examples/bug-fix/workflow.yaml new file mode 100644 index 0000000..9eda83d --- /dev/null +++ b/apps/stokowski/priv/examples/bug-fix/workflow.yaml @@ -0,0 +1,32 @@ +schema_version: 1 +config_version: 1 +project_name: fantasia-bug-fix-example +prompts: + global_prompt: + - prompts/global.md + - prompts/specialized.md +states: + reproduce: + type: agent + prompt: prompts/phase.md + transitions: {complete: diagnose} + diagnose: + type: agent + prompt: prompts/phase.md + transitions: {complete: ground-check} + ground-check: + type: agent + prompt: prompts/phase.md + session: fresh + transitions: {complete: review} + review: + type: gate + rework_to: diagnose + max_rework: 3 + transitions: {approve: implement} + implement: + type: agent + prompt: prompts/phase.md + transitions: {complete: done} + done: + type: terminal diff --git a/apps/stokowski/priv/examples/default/prompts/global.md b/apps/stokowski/priv/examples/default/prompts/global.md new file mode 100644 index 0000000..9c619ff --- /dev/null +++ b/apps/stokowski/priv/examples/default/prompts/global.md @@ -0,0 +1,4 @@ +# Fantasia workflow + +Follow the issue evidence and repository instructions. Report uncertainty +explicitly and leave the structured report requested by the lifecycle context. diff --git a/apps/stokowski/priv/examples/default/prompts/phase.md b/apps/stokowski/priv/examples/default/prompts/phase.md new file mode 100644 index 0000000..af0705f --- /dev/null +++ b/apps/stokowski/priv/examples/default/prompts/phase.md @@ -0,0 +1,8 @@ +Work only on the current phase: {{ issue.identifier }} — {{ issue.title }}. + +{% if issue.description %} +Issue description: +{{ issue.description }} +{% else %} +No description was provided. +{% endif %} diff --git a/apps/stokowski/priv/examples/default/workflow.yaml b/apps/stokowski/priv/examples/default/workflow.yaml new file mode 100644 index 0000000..147c759 --- /dev/null +++ b/apps/stokowski/priv/examples/default/workflow.yaml @@ -0,0 +1,40 @@ +schema_version: 1 +config_version: 1 +project_name: fantasia-example +prompts: + global_prompt: prompts/global.md +states: + investigate: + type: agent + prompt: prompts/phase.md + transitions: {complete: research-review} + research-review: + type: gate + rework_to: investigate + max_rework: 3 + transitions: {approve: implement} + implement: + type: agent + prompt: prompts/phase.md + transitions: {complete: implementation-review} + implementation-review: + type: gate + rework_to: implement + max_rework: 5 + transitions: {approve: code-review} + code-review: + type: agent + prompt: prompts/phase.md + session: fresh + transitions: {complete: merge-review} + merge-review: + type: gate + rework_to: implement + max_rework: 5 + transitions: {approve: merge} + merge: + type: agent + prompt: prompts/phase.md + transitions: {complete: done} + done: + type: terminal diff --git a/apps/stokowski/priv/examples/exploration/prompts/global.md b/apps/stokowski/priv/examples/exploration/prompts/global.md new file mode 100644 index 0000000..b3a09ae --- /dev/null +++ b/apps/stokowski/priv/examples/exploration/prompts/global.md @@ -0,0 +1,3 @@ +# Fantasia exploration workflow + +Separate observed facts from hypotheses. Every conclusion must point to the evidence that supports it. diff --git a/apps/stokowski/priv/examples/exploration/prompts/phase.md b/apps/stokowski/priv/examples/exploration/prompts/phase.md new file mode 100644 index 0000000..0e71263 --- /dev/null +++ b/apps/stokowski/priv/examples/exploration/prompts/phase.md @@ -0,0 +1,5 @@ +Investigate the current phase for {{ issue.identifier }} — {{ issue.title }}. + +{{ issue.description }} + +Do not turn an unverified hypothesis into a transition or recommendation. diff --git a/apps/stokowski/priv/examples/exploration/prompts/specialized.md b/apps/stokowski/priv/examples/exploration/prompts/specialized.md new file mode 100644 index 0000000..510d195 --- /dev/null +++ b/apps/stokowski/priv/examples/exploration/prompts/specialized.md @@ -0,0 +1,5 @@ +Exploration-specific guidance: + +- record uncertainty and competing explanations; +- test the smallest useful hypothesis; +- make the recommended next experiment concrete. diff --git a/apps/stokowski/priv/examples/exploration/workflow.yaml b/apps/stokowski/priv/examples/exploration/workflow.yaml new file mode 100644 index 0000000..302f1da --- /dev/null +++ b/apps/stokowski/priv/examples/exploration/workflow.yaml @@ -0,0 +1,28 @@ +schema_version: 1 +config_version: 1 +project_name: fantasia-exploration-example +prompts: + global_prompt: + - prompts/global.md + - prompts/specialized.md +states: + investigate: + type: agent + prompt: prompts/phase.md + transitions: {complete: ground-check} + ground-check: + type: agent + prompt: prompts/phase.md + session: fresh + transitions: {complete: synthesize} + synthesize: + type: agent + prompt: prompts/phase.md + transitions: {complete: review} + review: + type: gate + rework_to: investigate + max_rework: 2 + transitions: {approve: done} + done: + type: terminal diff --git a/apps/stokowski/priv/examples/feature/prompts/global.md b/apps/stokowski/priv/examples/feature/prompts/global.md new file mode 100644 index 0000000..add6166 --- /dev/null +++ b/apps/stokowski/priv/examples/feature/prompts/global.md @@ -0,0 +1,4 @@ +# Fantasia feature workflow + +Use the issue evidence and repository instructions as the source of truth. +Keep assumptions visible and report verification results explicitly. diff --git a/apps/stokowski/priv/examples/feature/prompts/phase.md b/apps/stokowski/priv/examples/feature/prompts/phase.md new file mode 100644 index 0000000..6cd344d --- /dev/null +++ b/apps/stokowski/priv/examples/feature/prompts/phase.md @@ -0,0 +1,6 @@ +Work only on the current phase for {{ issue.identifier }} — {{ issue.title }}. + +Issue description: +{{ issue.description }} + +Use a fresh session when the phase declares one, and leave evidence for the next phase. diff --git a/apps/stokowski/priv/examples/feature/prompts/specialized.md b/apps/stokowski/priv/examples/feature/prompts/specialized.md new file mode 100644 index 0000000..32508de --- /dev/null +++ b/apps/stokowski/priv/examples/feature/prompts/specialized.md @@ -0,0 +1,5 @@ +Feature-specific guidance: + +- preserve the requested behavior and existing interfaces; +- identify compatibility risks before implementation; +- prefer small, reviewable changes. diff --git a/apps/stokowski/priv/examples/feature/workflow.yaml b/apps/stokowski/priv/examples/feature/workflow.yaml new file mode 100644 index 0000000..a8cb6b9 --- /dev/null +++ b/apps/stokowski/priv/examples/feature/workflow.yaml @@ -0,0 +1,33 @@ +schema_version: 1 +config_version: 1 +project_name: fantasia-feature-example +prompts: + global_prompt: + - prompts/global.md + - prompts/specialized.md +states: + discover: + type: agent + prompt: prompts/phase.md + transitions: {complete: ground-check} + ground-check: + type: agent + prompt: prompts/phase.md + session: fresh + transitions: {complete: design-review} + design-review: + type: gate + rework_to: discover + max_rework: 3 + transitions: {approve: implement} + implement: + type: agent + prompt: prompts/phase.md + transitions: {complete: implementation-review} + implementation-review: + type: gate + rework_to: implement + max_rework: 3 + transitions: {approve: done} + done: + type: terminal diff --git a/apps/stokowski/test/fixtures/histories/README.md b/apps/stokowski/test/fixtures/histories/README.md new file mode 100644 index 0000000..a1aec80 --- /dev/null +++ b/apps/stokowski/test/fixtures/histories/README.md @@ -0,0 +1,21 @@ +# Continuum history fixtures + +These `.term` files are committed replay evidence, generated with the pinned +toolchain by `mix phase1.histories --write`. The task builds each input from +the same provider-neutral values used by the flow tests and writes the +in-memory Continuum journal with `Continuum.Test.dump_history!/3`. + +The fixtures and their exact event sequences are: + +| Fixture | Snapshot | Events | +| --- | --- | --- | +| `straight-through` | two-phase `history` workflow | `complete` | +| `approval` | `history` workflow with `work -> review -> done`, `max_rework: 2` | `complete`, `approve` | +| `rework` | same `history` workflow | `complete`, `rework("fix")`, `complete`, `approve` | +| `escalation` | same `history` workflow | `complete`, `rework("one")`, `complete`, `rework("two")`, `complete`, `rework("three")` | +| `external-terminal` | same `history` workflow | `terminal(:external)` | +| `default-eight-phase` | `priv/examples/default/workflow.yaml` | `complete`, `approve`, `complete`, `approve`, `complete`, `approve`, `complete` | + +The generator is intentionally opt-in because changing a fixture changes the +replay contract. Review the binary diff and the reducer/flow tests after +running it. diff --git a/apps/stokowski/test/fixtures/histories/approval.term b/apps/stokowski/test/fixtures/histories/approval.term new file mode 100644 index 0000000..4823750 Binary files /dev/null and b/apps/stokowski/test/fixtures/histories/approval.term differ diff --git a/apps/stokowski/test/fixtures/histories/default-eight-phase.term b/apps/stokowski/test/fixtures/histories/default-eight-phase.term new file mode 100644 index 0000000..a8820bb Binary files /dev/null and b/apps/stokowski/test/fixtures/histories/default-eight-phase.term differ diff --git a/apps/stokowski/test/fixtures/histories/escalation.term b/apps/stokowski/test/fixtures/histories/escalation.term new file mode 100644 index 0000000..300277e Binary files /dev/null and b/apps/stokowski/test/fixtures/histories/escalation.term differ diff --git a/apps/stokowski/test/fixtures/histories/external-terminal.term b/apps/stokowski/test/fixtures/histories/external-terminal.term new file mode 100644 index 0000000..ad7cc07 Binary files /dev/null and b/apps/stokowski/test/fixtures/histories/external-terminal.term differ diff --git a/apps/stokowski/test/fixtures/histories/rework.term b/apps/stokowski/test/fixtures/histories/rework.term new file mode 100644 index 0000000..4066a03 Binary files /dev/null and b/apps/stokowski/test/fixtures/histories/rework.term differ diff --git a/apps/stokowski/test/fixtures/histories/straight-through.term b/apps/stokowski/test/fixtures/histories/straight-through.term new file mode 100644 index 0000000..42585cb Binary files /dev/null and b/apps/stokowski/test/fixtures/histories/straight-through.term differ diff --git a/apps/stokowski/test/stokowski/config_phase1_test.exs b/apps/stokowski/test/stokowski/config_phase1_test.exs new file mode 100644 index 0000000..886e72e --- /dev/null +++ b/apps/stokowski/test/stokowski/config_phase1_test.exs @@ -0,0 +1,225 @@ +defmodule Stokowski.ConfigPhase1Test do + use ExUnit.Case, async: true + + alias Stokowski.{Config, Domain} + + @default Path.expand("../../priv/examples/default/workflow.yaml", __DIR__) + + test "the package default has eight executable phases and a terminal" do + assert {:ok, snapshot} = Config.load(@default) + assert snapshot.entry_phase == "investigate" + assert map_size(snapshot.graph) == 8 + assert Enum.count(snapshot.graph, fn {_name, phase} -> phase.type == :agent end) == 4 + assert Enum.count(snapshot.graph, fn {_name, phase} -> phase.type == :gate end) == 3 + assert Enum.count(snapshot.graph, fn {_name, phase} -> phase.type == :terminal end) == 1 + assert snapshot.graph["investigate"].prompt =~ "{{ issue.identifier }}" + assert snapshot.prompts.global != [] + end + + test "routing is ordered and case insensitive" do + raw = %{ + "routing" => %{ + "default" => "feature", + "rules" => [ + %{"label" => "bug", "workflow" => "bug-fix"}, + %{"label" => "spike", "workflow" => "exploration"} + ] + } + } + + assert {:ok, "bug-fix", %{default: false}} = Config.route(raw, ["Spike", "BUG"]) + assert {:ok, "feature", %{default: true}} = Config.route(raw, []) + end + + test "routing provenance does not change the resolved workflow fingerprint" do + yaml = """ + routing: + default: feature + rules: + - {label: bug, workflow: feature} + workflows: + feature: + states: + work: {type: agent, prompt: phase.md, transitions: {complete: done}} + done: {type: terminal} + """ + + opts = [prompt_contents: %{"phase.md" => "phase"}] + assert {:ok, routed} = Config.from_yaml(yaml, Keyword.put(opts, :labels, ["bug"])) + assert {:ok, explicit} = Config.from_yaml(yaml, Keyword.put(opts, :workflow, "feature")) + assert routed.graph == explicit.graph + assert routed.routing != explicit.routing + assert routed.fingerprint == explicit.fingerprint + end + + test "reports the routed workflow name when a route is undefined" do + yaml = """ + routing: + default: missing + workflows: + defined: + states: + work: {type: agent, prompt: phase.md, transitions: {complete: done}} + done: {type: terminal} + """ + + assert {:error, {:unknown_workflow, "missing"}} = + Config.from_yaml(yaml, prompt_contents: %{"phase.md" => "phase"}) + end + + test "loader metadata preserves declaration order for named inline workflows" do + yaml = """ + routing: {default: custom} + workflows: + custom: + states: + done: {type: terminal} + work: {type: agent, prompt: phase.md, transitions: {complete: done}} + """ + + assert {:ok, raw, ""} = Config.Loader.parse(yaml) + assert raw["__workflow_phase_orders__"]["custom"] == ["done", "work"] + assert {:ok, snapshot} = Config.from_yaml(yaml, prompt_contents: %{"phase.md" => "phase"}) + assert map_size(snapshot.graph) == 2 + refute Map.has_key?(snapshot.graph, "__phase_order__") + end + + @tag :tmp_dir + test "rejects prompt paths outside the workflow directory", %{tmp_dir: tmp_dir} do + yaml = """ + states: + work: {type: agent, prompt: ../outside.md, transitions: {complete: done}} + done: {type: terminal} + """ + + assert {:error, {:prompt_path_outside_workflow, "../outside.md"}} = + Config.from_yaml(yaml, workflow_dir: Path.join(tmp_dir, "workflow")) + end + + @tag :tmp_dir + test "preserves external declaration order without treating loader metadata as a phase", + %{tmp_dir: tmp_dir} do + workflow = Path.join(tmp_dir, "workflow.yaml") + external_dir = Path.join(tmp_dir, "workflows") + File.mkdir_p!(external_dir) + File.write!(Path.join(tmp_dir, "prompt.md"), "phase") + + File.write!( + Path.join(external_dir, "ordered.yaml"), + """ + done: {type: terminal} + work: {type: agent, prompt: prompt.md, transitions: {complete: done}} + """ + ) + + File.write!(workflow, "routing: {default: ordered}\n") + + assert {:ok, raw, ""} = Config.Loader.read(Path.join(external_dir, "ordered.yaml")) + assert raw["__phase_order__"] == ["done", "work"] + assert {:ok, snapshot} = Config.load(workflow) + assert map_size(snapshot.graph) == 2 + refute Map.has_key?(snapshot.graph, "__phase_order__") + end + + test "specialized examples keep grounding as a fresh data-defined phase" do + for kind <- ["bug-fix", "feature", "exploration"] do + path = Path.expand("../../priv/examples/#{kind}/workflow.yaml", __DIR__) + assert {:ok, snapshot} = Config.load(path) + assert {:ok, _ground_check} = Map.fetch(snapshot.graph, "ground-check") + assert snapshot.graph["ground-check"].session == :fresh + assert snapshot.prompts.global |> length() == 2 + end + end + + test "fingerprints include resolved prompts but exclude tracker credentials" do + base = """ + project_name: example + tracker: + api_key: literal-secret + prompts: + global_prompt: global.md + states: + start: + type: agent + prompt: phase.md + transitions: {complete: done} + done: + type: terminal + """ + + opts = [prompt_contents: %{"global.md" => "global one", "phase.md" => "phase one"}] + assert {:ok, first} = Config.from_yaml(base, opts) + assert first.fingerprint == Config.fingerprint(first) + refute first.fingerprint =~ "secret" + + assert {:ok, changed} = + Config.from_yaml( + base, + Keyword.put(opts, :prompt_contents, %{ + "global.md" => "global two", + "phase.md" => "phase one" + }) + ) + + refute first.fingerprint == changed.fingerprint + end + + test "preserves a zero rework limit in the normalized phase" do + yaml = """ + states: + work: {type: agent, prompt: phase.md, transitions: {complete: review}} + review: + type: gate + rework_to: work + max_rework: 0 + transitions: {approve: done} + done: {type: terminal} + """ + + assert {:ok, snapshot} = Config.from_yaml(yaml, prompt_contents: %{"phase.md" => "phase"}) + assert snapshot.graph["review"].max_rework == 0 + end + + test "legacy markdown without a graph gives a migration diagnostic" do + assert {:error, {:legacy_migration_required, message}} = + Config.from_yaml("---\nproject_name: old\n---\nA free-form workflow") + + assert message =~ "deterministic phase graph" + end + + test "phase reducer distinguishes run rework from attempt number" do + {:ok, snapshot} = Config.load(@default) + state = %Domain.PhaseState{phase: "research-review", run: 1, attempt: 4, status: :waiting} + + assert {:ok, next, [_rework_effect, effect]} = + Stokowski.PhaseMachine.reduce(snapshot, state, Domain.rework("missing test"), + project: "p", + issue: "i" + ) + + assert next.phase == "investigate" + assert next.run == 2 + assert next.attempt == 1 + assert effect.kind == :dispatch + assert effect.attempt.phase == "investigate" + assert effect.attempt.run == 2 + assert effect.attempt.attempt == 1 + end + + test "failures and prose do not advance the graph" do + {:ok, snapshot} = Config.load(@default) + state = %Domain.PhaseState{phase: "investigate"} + + assert {:ok, failed, [_effect]} = + Stokowski.PhaseMachine.reduce(snapshot, state, Domain.failed(:timeout)) + + assert failed.phase == "investigate" + assert failed.status == :failed + + assert {:error, {:invalid_event, :approve, :agent}} = + Stokowski.PhaseMachine.reduce(snapshot, state, Domain.approve()) + + assert {:error, {:unknown_event, "write_code"}} = + Stokowski.PhaseMachine.reduce(snapshot, state, %{"type" => "write_code"}) + end +end diff --git a/apps/stokowski/test/stokowski/flow_phase1_test.exs b/apps/stokowski/test/stokowski/flow_phase1_test.exs new file mode 100644 index 0000000..3babca5 --- /dev/null +++ b/apps/stokowski/test/stokowski/flow_phase1_test.exs @@ -0,0 +1,85 @@ +defmodule Stokowski.FlowPhase1Test do + use ExUnit.Case, async: false + + alias Continuum.Test, as: ContinuumTest + alias Stokowski.Domain + + setup_all do + {:ok, _applications} = Application.ensure_all_started(:continuum) + :ok + end + + setup do + ContinuumTest.reset_in_memory!() + :ok + end + + test "fake eight-phase history completes and replays after caller input changes" do + {:ok, snapshot} = + Stokowski.Config.load(Path.expand("../../priv/examples/default/workflow.yaml", __DIR__)) + + events = [ + Domain.agent_completed(), + Domain.approve(), + Domain.agent_completed(), + Domain.approve(), + Domain.agent_completed(), + Domain.approve(), + Domain.agent_completed() + ] + + {:ok, run_id} = + ContinuumTest.start_synchronous(Stokowski.Flow, %{snapshot: snapshot, events: events}) + + assert {:ok, %{state: :completed, result: {:ok, %{state: %{phase: "done"}}}}} = + Continuum.await(run_id, 1_000) + + history = ContinuumTest.history(run_id) + assert hd(history).type == :side_effect + assert Enum.count(history, &(&1.type == :activity_completed)) == 7 + + changed = %{snapshot: %{fingerprint: "changed"}, events: [Domain.failed(:changed)]} + + assert {:ok, {:ok, %{state: %{phase: "done"}}}} = + ContinuumTest.replay(Stokowski.Flow, changed, history) + end + + test "terminal signal cancels a non-terminal flow without selecting an edge" do + {:ok, snapshot} = + Stokowski.Config.load(Path.expand("../../priv/examples/default/workflow.yaml", __DIR__)) + + {:ok, run_id} = + ContinuumTest.start_synchronous(Stokowski.Flow, %{ + snapshot: snapshot, + events: [Domain.terminal(:external)] + }) + + assert {:ok, + %{ + state: :completed, + result: {:ok, %{state: %{status: :cancelled, phase: "investigate"}}} + }} = Continuum.await(run_id, 1_000) + end + + test "committed histories cover the deterministic terminal outcomes" do + expected = %{ + "default-eight-phase" => {:completed, "done", 1}, + "straight-through" => {:completed, "done", 1}, + "approval" => {:completed, "done", 1}, + "rework" => {:completed, "done", 2}, + "escalation" => {:escalated, "review", 3}, + "external-terminal" => {:cancelled, "work", 1} + } + + for {name, {status, phase, run}} <- expected do + history = + Path.join(Path.expand("../fixtures/histories", __DIR__), name <> ".term") + |> Continuum.Test.load_history!() + + changed = %{snapshot: %{fingerprint: "caller-change"}, events: [Domain.failed(:ignored)]} + + assert {:ok, {:ok, %{state: %Domain.PhaseState{status: ^status, phase: ^phase, run: ^run}}}} = + Continuum.Test.replay(Stokowski.Flow, changed, history) + end + end +end diff --git a/apps/stokowski/test/stokowski/phase1_core_test.exs b/apps/stokowski/test/stokowski/phase1_core_test.exs new file mode 100644 index 0000000..2f9bc15 --- /dev/null +++ b/apps/stokowski/test/stokowski/phase1_core_test.exs @@ -0,0 +1,326 @@ +defmodule Stokowski.Phase1CoreTest do + use ExUnit.Case, async: false + + alias Stokowski.{Config, Domain, PhaseMachine, Prompt, Report, Tracking} + + setup do + Continuum.Test.reset_in_memory!() + :ok + end + + test "the package-owned default is the complete eight-phase graph" do + path = Path.expand("../../priv/examples/default/workflow.yaml", __DIR__) + assert {:ok, snapshot} = Config.load(path) + assert map_size(snapshot.graph) == 8 + assert snapshot.entry_phase == "investigate" + assert snapshot.graph["investigate"].prompt =~ "current phase" + assert Config.validate(snapshot) == :ok + end + + test "ordered routing and resolved prompts are included in the fingerprint" do + yaml = """ + project_name: example + routing: + default: feature + rules: + - {label: bug, workflow: bug} + - {label: spike, workflow: exploration} + workflows: + feature: + prompts: {global_prompt: [global, extra]} + states: + work: {type: agent, prompt: phase, transitions: {complete: done}} + done: {type: terminal} + bug: + prompts: {global_prompt: global} + states: + reproduce: {type: agent, prompt: phase, transitions: {complete: done}} + done: {type: terminal} + exploration: + prompts: {global_prompt: global} + states: + explore: {type: agent, prompt: phase, transitions: {complete: done}} + done: {type: terminal} + """ + + provided = %{"global" => "global", "extra" => "extra", "phase" => "phase one"} + assert {:ok, bug} = Config.from_yaml(yaml, labels: [" BUG "], prompt_contents: provided) + assert bug.workflow == "bug" + assert bug.routing.label == "bug" + assert bug.graph["reproduce"].prompt == "phase one" + assert bug.prompts.global == ["global"] + + assert {:ok, feature} = Config.from_yaml(yaml, labels: [], prompt_contents: provided) + refute bug.fingerprint == feature.fingerprint + end + + test "legacy markdown preserves the body but rejects prose-only transitions" do + markdown = """ + --- + tracker: + kind: linear + --- + This is the historical prompt. + """ + + assert {:ok, _raw, body} = Stokowski.Config.Loader.parse(markdown) + assert body =~ "historical prompt" + assert {:error, {:legacy_migration_required, message}} = Stokowski.Config.from_yaml(markdown) + assert message =~ "deterministic phase graph" + end + + test "the reducer accepts only typed events and emits stable ordered effects" do + snapshot = snapshot() + state = PhaseMachine.initial(snapshot) + + assert {:ok, gate, [%Domain.RequiredEffect{kind: :dispatch}]} = + PhaseMachine.reduce(snapshot, state, Domain.agent_completed()) + + assert gate.phase == "review" + assert gate.attempt == 1 + + assert {:ok, done, [%Domain.RequiredEffect{kind: :complete}]} = + PhaseMachine.reduce(snapshot, gate, Domain.approve()) + + assert done.status == :completed + + assert {:ok, same, [%Domain.RequiredEffect{kind: :report_failure}]} = + PhaseMachine.reduce( + snapshot, + state, + Domain.failed(%Domain.Failure{class: :runner, reason: :boom}) + ) + + assert same.phase == state.phase + + assert {:error, {:invalid_event, :approve, :agent}} = + PhaseMachine.reduce(snapshot, state, Domain.approve()) + + assert PhaseMachine.dispatch_effect(snapshot, state).id == + PhaseMachine.dispatch_effect(snapshot, state).id + + assert {:ok, cancelled, [%Domain.RequiredEffect{kind: :cancel}]} = + PhaseMachine.reduce(snapshot, state, Domain.terminal(:closed)) + + assert cancelled.status == :cancelled + end + + test "gate rework increments run and escalates at the configured ceiling" do + snapshot = snapshot() + gate = %Domain.PhaseState{phase: "review", run: 1, attempt: 2, status: :waiting} + assert {:ok, work, effects} = PhaseMachine.reduce(snapshot, gate, Domain.rework("fix this")) + assert work.phase == "work" + assert work.run == 2 + assert work.rework_counts == %{"review" => 1} + assert Enum.map(effects, & &1.kind) == [:rework, :dispatch] + + assert {:ok, work_again, _effects} = + PhaseMachine.reduce( + snapshot, + %{work | phase: "review", status: :waiting}, + Domain.rework("again") + ) + + assert work_again.run == 3 + assert work_again.rework_counts == %{"review" => 2} + + ceiling = %{work_again | phase: "review", status: :waiting} + + assert {:ok, escalated, [%Domain.RequiredEffect{kind: :escalate}]} = + PhaseMachine.reduce(snapshot, ceiling, Domain.rework("third time")) + + assert escalated.status == :escalated + end + + test "rework budgets are independent per gate and zero means no rework" do + snapshot = snapshot() + other_gate = %{snapshot.graph["review"] | name: "other-review", max_rework: 1} + snapshot = %{snapshot | graph: Map.put(snapshot.graph, "other-review", other_gate)} + + state = %Domain.PhaseState{ + phase: "review", + run: 8, + status: :waiting, + rework_counts: %{"other-review" => 1} + } + + assert {:ok, next, _effects} = + PhaseMachine.reduce(snapshot, state, Domain.rework("review only")) + + assert next.rework_counts == %{"other-review" => 1, "review" => 1} + + zero = %{snapshot | graph: Map.put(snapshot.graph, "review", %{other_gate | max_rework: 0})} + + assert {:ok, escalated, [%Domain.RequiredEffect{kind: :escalate}]} = + PhaseMachine.reduce(zero, %{state | rework_counts: %{}}, Domain.rework("no retry")) + + assert escalated.status == :escalated + end + + test "atom-keyed event maps retain their payload and support escalation" do + assert {:ok, %Domain.GateDecision{decision: :rework, feedback: "fix", actor: "Ada"}} = + Domain.normalize_event(%{type: "rework", feedback: "fix", actor: "Ada"}) + + assert {:ok, %Domain.GateDecision{decision: :escalate}} = Domain.normalize_event(:escalate) + end + + test "prompt rendering supports nested and flat values without code evaluation" do + issue = %Domain.Issue{id: "i", identifier: "EXT-1", title: "Title", labels: ["Bug", "spike"]} + + template = + "{{ issue.identifier }} {{ issue_labels | lower }} {% if issue.description %}bad{% else %}missing{% endif %}" + + assert Prompt.render(template, Prompt.context(issue)) == "EXT-1 bug, spike missing" + refute Prompt.render("{{ File.read! }}", Prompt.context(issue)) =~ "File" + end + + test "tracking reads both formats, orders equal timestamps stably, and writes v1 only" do + comments = [ + %{ + "id" => "b", + "createdAt" => "2026-01-01T00:00:01Z", + "body" => + "" + }, + %{ + "id" => "a", + "createdAt" => "2026-01-01T00:00:02Z", + "body" => + "" + } + ] + + assert {:ok, %{payload: %{"state" => "new"}}} = Tracking.latest(comments, "state") + assert Tracking.parse_latest_tracking(comments)["state"] == "new" + + assert {:ok, marker} = + Tracking.write_state("review", ~U[2026-01-01 00:00:00Z], "effect:1", run: 2) + + assert marker =~ "fantasia:v1:state" + refute marker =~ "stokowski:" + assert {:error, :timestamp_required} = Tracking.write_state("review", nil, "effect:1") + end + + test "structured report decoding keeps unavailable fields explicit" do + assert {:ok, report} = + Report.decode(%{"verdict" => "complete", "claims" => [%{"claim" => "done"}]}) + + assert report.verdict == "complete" + assert report.data_sources == [] + assert Report.render(report) =~ "Complete" + end + + test "the Continuum flow replays the journaled snapshot and supports rework and escalation" do + snapshot = snapshot() + input = %{snapshot: snapshot, events: [Domain.agent_completed(), Domain.approve()]} + assert {:ok, run_id} = Continuum.Test.start_synchronous(Stokowski.Flow, input) + + assert {:ok, + %{state: :completed, result: {:ok, %{state: %Domain.PhaseState{status: :completed}}}} = + result} = + Continuum.await(run_id, 1_000) + + history = Continuum.Test.history(run_id) + + changed = %{snapshot: %{snapshot | fingerprint: "changed"}, events: [Domain.failed(:ignored)]} + expected = result.result + assert {:ok, ^expected} = Continuum.Test.replay(Stokowski.Flow, changed, history) + + rework_input = %{ + snapshot: snapshot, + events: [ + Domain.agent_completed(), + Domain.rework("fix"), + Domain.agent_completed(), + Domain.approve() + ] + } + + assert {:ok, rework_id} = Continuum.Test.start_synchronous(Stokowski.Flow, rework_input) + + assert {:ok, + %{ + state: :completed, + result: {:ok, %{state: %Domain.PhaseState{status: :completed, run: 2}}} + }} = + Continuum.await(rework_id, 1_000) + + escalation = %{ + snapshot: snapshot, + events: [ + Domain.agent_completed(), + Domain.rework("one"), + Domain.agent_completed(), + Domain.rework("two"), + Domain.agent_completed(), + Domain.rework("three") + ] + } + + assert {:ok, escalation_id} = Continuum.Test.start_synchronous(Stokowski.Flow, escalation) + + assert {:ok, + %{ + state: :completed, + result: {:ok, %{state: %Domain.PhaseState{status: :escalated, run: 3}}} + }} = + Continuum.await(escalation_id, 1_000) + end + + test "a waiting flow consumes typed terminal signals" do + input = %{snapshot: snapshot(), events: [Domain.agent_completed()], await_signal: true} + assert {:ok, run_id} = Continuum.Test.start_synchronous(Stokowski.Flow, input) + assert {:error, :timeout} = Continuum.await(run_id, 100) + assert :ok = Continuum.Test.inject_signal(run_id, :phase_event, Domain.terminal(:closed)) + + assert {:ok, + %{state: :completed, result: {:ok, %{state: %Domain.PhaseState{status: :cancelled}}}}} = + Continuum.await(run_id, 1_000) + end + + test "an awaiting flow continues through rework after returning to an agent" do + input = %{ + snapshot: snapshot(), + events: [Domain.agent_completed(), Domain.rework("fix")], + await_signal: true + } + + assert {:ok, run_id} = Continuum.Test.start_synchronous(Stokowski.Flow, input) + assert {:error, :timeout} = Continuum.await(run_id, 100) + assert :ok = Continuum.Test.inject_signal(run_id, :phase_event, Domain.agent_completed()) + assert {:error, :timeout} = Continuum.await(run_id, 100) + assert :ok = Continuum.Test.inject_signal(run_id, :phase_event, Domain.approve()) + + assert {:ok, + %{state: :completed, result: {:ok, %{state: %Domain.PhaseState{status: :completed}}}}} = + Continuum.await(run_id, 1_000) + end + + defp snapshot do + %Domain.WorkflowSnapshot{ + workflow: "test", + entry_phase: "work", + graph: %{ + "work" => %Domain.Phase{ + name: "work", + type: :agent, + prompt: "work", + transitions: %{"complete" => "review"} + }, + "review" => %Domain.Phase{ + name: "review", + type: :gate, + rework_to: "work", + max_rework: 2, + transitions: %{"approve" => "done"} + }, + "done" => %Domain.Phase{name: "done", type: :terminal} + }, + prompts: %{}, + routing: %{issue: "EXT-1"}, + schema_version: 1, + config_version: 1, + fingerprint: "sha256:test" + } + end +end diff --git a/apps/stokowski/test/stokowski/prompt_report_tracking_phase1_test.exs b/apps/stokowski/test/stokowski/prompt_report_tracking_phase1_test.exs new file mode 100644 index 0000000..345d1ab --- /dev/null +++ b/apps/stokowski/test/stokowski/prompt_report_tracking_phase1_test.exs @@ -0,0 +1,201 @@ +defmodule Stokowski.PromptReportTrackingPhase1Test do + use ExUnit.Case, async: true + + alias Stokowski.{Prompt, Report, Tracking} + alias Stokowski.Domain + + test "prompt subset supports nested and flat context, conditionals, and lower" do + issue = %Domain.Issue{ + id: "id", + identifier: "EXT-1", + title: "Title", + description: "Details", + labels: ["Bug", "Feature"] + } + + template = + "{{ issue.identifier }} / {{ issue_identifier }} / {{ issue.labels | lower }} {% if issue.description %}yes{% else %}no{% endif %} {{ issue.missing }}" + + rendered = Prompt.render(template, Prompt.context(issue)) + + assert rendered =~ "EXT-1 / EXT-1 / bug, feature yes" + refute rendered =~ "issue.missing" + + assert Prompt.render( + "{% if issue.description %}yes{% else %}no{% endif %}", + Prompt.context(%{identifier: "x"}) + ) == "no" + end + + test "all vendored prompt examples render through the safe subset" do + prompt_dir = Path.expand("../../../../vendor/stokowski/prompts", __DIR__) + + issue = %Domain.Issue{ + id: "id", + identifier: "EXT-1", + title: "Example issue", + description: "Example description", + state: "In Progress", + url: "https://example.test/EXT-1", + labels: ["bug"] + } + + for path <- Path.wildcard(Path.join(prompt_dir, "*.example.md")) do + rendered = Prompt.render(File.read!(path), Prompt.context(issue)) + assert rendered != "" + refute rendered =~ "{{" + end + end + + test "assembled prompt order and lifecycle contract are deterministic" do + {:ok, snapshot} = + Stokowski.Config.load(Path.expand("../../priv/examples/default/workflow.yaml", __DIR__)) + + issue = %Domain.Issue{id: "id", identifier: "EXT-1", title: "Title", description: "Details"} + + prompt = + Prompt.assemble(snapshot, issue, "investigate", + comments: [ + %{id: "2", body: "later", createdAt: "2026-01-02T00:00:00Z", author: "Ada"}, + %{id: "1", body: "earlier", createdAt: "2026-01-01T00:00:00Z", author: "Lin"}, + %{ + id: "machine", + body: + ~s(), + createdAt: "2026-01-03T00:00:00Z" + } + ] + ) + + assert prompt =~ "# Fantasia workflow" + assert prompt =~ "EXT-1" + assert prompt =~ "## Lifecycle Context" + assert prompt =~ "### Structured reporting" + assert :binary.match(prompt, "earlier") < :binary.match(prompt, "later") + refute prompt =~ "machine" + end + + test "report projection exposes unsupported evidence instead of hiding it" do + assert {:ok, report} = + Report.decode( + ~s({"verdict":"needs-rework","claims":[{"claim":"A claim","confidence":"low"}]}) + ) + + output = Report.render(report) + assert output =~ "Needs rework" + assert output =~ "A claim" + assert output =~ "Findings" + end + + test "report rendering handles next steps, scalar maps, and missing captions" do + output = + Report.render(%{ + "verdict" => "approve", + "summary" => %{"result" => "ready"}, + "next_steps" => ["run tests", "merge the change"], + "artifacts" => [%{"file" => "screenshot.png"}] + }) + + assert output =~ "> 1. run tests" + assert output =~ "> 2. merge the change" + assert output =~ "result" + assert output =~ "[screenshot.png](screenshot.png)" + refute output =~ "[](screenshot.png)" + end + + @tag :tmp_dir + test "report loading falls back to a fenced JSON block", %{tmp_dir: tmp_dir} do + fallback = """ + Runner output: + ```json + {"verdict":"complete","summary":"finished"} + ``` + """ + + assert {:ok, report} = Report.load(tmp_dir, fallback) + assert report.verdict == "complete" + assert report.summary == "finished" + end + + test "comment truncation keeps multibyte prompt content valid" do + snapshot = %Domain.WorkflowSnapshot{ + workflow: "test", + entry_phase: "work", + graph: %{}, + prompts: %{}, + routing: %{}, + schema_version: 1, + config_version: 1, + fingerprint: "test" + } + + lifecycle = + Prompt.lifecycle_data( + snapshot, + %Domain.Issue{id: "id", identifier: "EXT-1", title: "Title"}, + %Domain.PhaseState{phase: "work"}, + nil, + comments: [%{"id" => "1", "body" => String.duplicate("é", 20)}], + max_chars: 10 + ) + + assert String.valid?(lifecycle["comments"]) + assert String.valid?(Prompt.lifecycle_markdown(lifecycle)) + end + + test "tracking dual-read is stable and writers require data" do + comments = [ + %{ + "id" => "a", + "createdAt" => "2026-01-01T00:00:00Z", + "body" => ~s() + }, + %{ + "id" => "b", + "createdAt" => "2026-01-01T00:00:00Z", + "body" => + ~s() + }, + %{ + "id" => "c", + "createdAt" => "2026-01-02T00:00:00Z", + "body" => "ordinary feedback", + "user" => %{"displayName" => "Ada"} + }, + %{ + "id" => "d", + "createdAt" => "2026-01-02T00:00:01Z", + "body" => + ~s() + } + ] + + assert {:ok, marker} = Tracking.latest(Enum.take(comments, 3), "state") + assert marker.payload["state"] == "new" + assert {:error, :timestamp_required} = Tracking.write_state("new", nil, "effect-1") + + assert {:ok, body} = + Tracking.write_gate("review", "waiting", "2026-01-03T00:00:00Z", "effect-2", run: 2) + + assert body =~ "fantasia:v1:gate" + assert {:ok, gate} = Tracking.latest([%{"body" => body}], "gate") + assert gate.payload["run"] == 2 + + [feedback] = Tracking.recent_comments(comments, "2026-01-01T00:00:00Z") + assert feedback.author == "Ada" + assert feedback.body == "ordinary feedback" + end + + test "legacy gate markers preserve statuses outside the native v1 set" do + comments = [ + %{ + "id" => "legacy", + "body" => + ~s() + } + ] + + assert {:ok, marker} = Tracking.latest(comments, "gate") + assert marker.payload["status"] == "waiting_for_operator" + end +end diff --git a/config/config.exs b/config/config.exs index fd756a3..22239b7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -9,6 +9,8 @@ # move said applications out of the umbrella. import Config +config :continuum, trusted_modules: [:crypto, Stokowski.Deterministic] + # Sample configuration: # # config :logger, :default_handler, diff --git a/documents/decisions/0007-phase-1-deterministic-core.adoc b/documents/decisions/0007-phase-1-deterministic-core.adoc new file mode 100644 index 0000000..7539866 --- /dev/null +++ b/documents/decisions/0007-phase-1-deterministic-core.adoc @@ -0,0 +1,72 @@ += Phase 1 deterministic core + +== Status + +Accepted for EXT-58. + +== Decision + +Fantasia keeps the lossless `Stokowski.Workflow` YAML decoder as the parser +boundary and adds `Stokowski.Config` as the normalization boundary. A +configuration snapshot contains the selected workflow, entry phase, graph, +resolved ordered prompt bodies, routing decision, capability declarations, +and schema/config versions. Tracker credentials, environment values, absolute +checkout paths, and timestamps are excluded. The SHA-256 fingerprint is made +from canonical sorted data and therefore changes when a prompt, route, graph, +or selected workflow changes. + +Canonical YAML and legacy `WORKFLOW.md` front matter are accepted. A legacy +markdown body is preserved in the snapshot, but a document without a +deterministic phase graph returns an actionable migration diagnostic; state +transitions are never inferred from prose or provider state. + +== Prompt and report boundary + +The native prompt implementation intentionally supports only the subset used +by the vendored corpus: dotted `issue.*` values, flat aliases, permissive +missing values, `if`/`else`/`endif`, and the `lower` filter. It does not execute +template code. Prompt assembly is global stack, phase body, and generated +Fantasia lifecycle/report contract. Structured reports are decoded into +provider-neutral values and rendered by Fantasia so model prose cannot hide +missing evidence. + +== Tracking and history + +Tracking readers accept legacy `stokowski:` and `fantasia:v1:` markers, +validate embedded timestamps, and order equal timestamps by normalized comment +identity/content. Writers require timestamp and effect identity as arguments +and emit only Fantasia v1 markers. The generic Continuum flow journals its +entire normalized input before invoking the pure reducer and calls one fixed +fake activity boundary. The Phase 0 flow and its history remain unchanged; +incompatible future flow changes require a Continuum workflow version bump. + +== Rework and deterministic helper decisions + +`max_rework` is a per-gate limit on rework transitions, not a workflow-global +run limit. `run` remains a one-based lifecycle counter and increments on every +rework, while the reducer stores a counter keyed by the gate name. A gate with +`max_rework: 0` escalates on its first rework; a gate with `N` permits `N` +rework transitions before escalation. This keeps budgets independent when a +workflow contains multiple gates and makes the zero value explicit. + +The reducer's effect identity uses an audited `Stokowski.Deterministic` helper +for deterministic external-term encoding and SHA-256. Only that narrow owned +helper and `:crypto` are trusted by Continuum; the complete `:erlang` module is +not allowlisted. + +Declaration order is loader metadata carried onto each selected workflow. It +is never mixed into a bare state map, so external state-only workflow files do +not grow a phantom `__phase_order__` phase and inline named workflows retain +their authored order. + +== Ash domain/resource/action disposition + +Ash is explicitly deferred for this Phase 1 core. Continuum owns execution and +replay, and the pure `Stokowski.Domain` structs are the provider-neutral input +to that reducer. There is no persistent or queryable operational domain in +this slice, so introducing Ash resources and actions would duplicate the +durable workflow boundary without adding a storage contract. The initial plan +places Ash behind a separate ADR for precisely this case. A future persistent +operational domain may adopt an Ash domain/resources/actions layer, with an +explicit migration boundary from these immutable values; it must not replace +the pure reducer or make replay depend on Ash state.