From 6291d6a5df6f70516ab2f5f861e054d651659d84 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Mon, 14 Sep 2026 20:24:10 -0400 Subject: [PATCH 1/2] feat(stokowski): add deterministic phase one core --- apps/stokowski/lib/stokowski/config.ex | 788 ++++++++++++++++++ apps/stokowski/lib/stokowski/domain.ex | 339 ++++++++ apps/stokowski/lib/stokowski/flow.ex | 113 +++ apps/stokowski/lib/stokowski/phase_machine.ex | 254 ++++++ apps/stokowski/lib/stokowski/prompt.ex | 449 ++++++++++ apps/stokowski/lib/stokowski/report.ex | 410 +++++++++ apps/stokowski/lib/stokowski/tracking.ex | 345 +++++++- apps/stokowski/lib/stokowski/workflow.ex | 10 +- .../priv/examples/bug-fix/prompts/global.md | 3 + .../priv/examples/bug-fix/prompts/phase.md | 1 + .../examples/bug-fix/prompts/specialized.md | 1 + .../priv/examples/bug-fix/workflow.yaml | 32 + .../priv/examples/default/prompts/global.md | 4 + .../priv/examples/default/prompts/phase.md | 8 + .../priv/examples/default/workflow.yaml | 40 + .../examples/exploration/prompts/global.md | 3 + .../examples/exploration/prompts/phase.md | 5 + .../exploration/prompts/specialized.md | 5 + .../priv/examples/exploration/workflow.yaml | 28 + .../priv/examples/feature/prompts/global.md | 4 + .../priv/examples/feature/prompts/phase.md | 6 + .../examples/feature/prompts/specialized.md | 5 + .../priv/examples/feature/workflow.yaml | 33 + .../test/fixtures/histories/approval.term | Bin 0 -> 3097 bytes .../histories/default-eight-phase.term | Bin 0 -> 11726 bytes .../test/fixtures/histories/escalation.term | Bin 0 -> 5754 bytes .../fixtures/histories/external-terminal.term | Bin 0 -> 2200 bytes .../test/fixtures/histories/rework.term | Bin 0 -> 5586 bytes .../fixtures/histories/straight-through.term | Bin 0 -> 1928 bytes .../test/stokowski/config_phase1_test.exs | 119 +++ .../test/stokowski/flow_phase1_test.exs | 85 ++ .../test/stokowski/phase1_core_test.exs | 260 ++++++ .../prompt_report_tracking_phase1_test.exs | 119 +++ config/config.exs | 2 + .../0007-phase-1-deterministic-core.adoc | 41 + 35 files changed, 3499 insertions(+), 13 deletions(-) create mode 100644 apps/stokowski/lib/stokowski/config.ex create mode 100644 apps/stokowski/lib/stokowski/domain.ex create mode 100644 apps/stokowski/lib/stokowski/flow.ex create mode 100644 apps/stokowski/lib/stokowski/phase_machine.ex create mode 100644 apps/stokowski/lib/stokowski/prompt.ex create mode 100644 apps/stokowski/lib/stokowski/report.ex create mode 100644 apps/stokowski/priv/examples/bug-fix/prompts/global.md create mode 100644 apps/stokowski/priv/examples/bug-fix/prompts/phase.md create mode 100644 apps/stokowski/priv/examples/bug-fix/prompts/specialized.md create mode 100644 apps/stokowski/priv/examples/bug-fix/workflow.yaml create mode 100644 apps/stokowski/priv/examples/default/prompts/global.md create mode 100644 apps/stokowski/priv/examples/default/prompts/phase.md create mode 100644 apps/stokowski/priv/examples/default/workflow.yaml create mode 100644 apps/stokowski/priv/examples/exploration/prompts/global.md create mode 100644 apps/stokowski/priv/examples/exploration/prompts/phase.md create mode 100644 apps/stokowski/priv/examples/exploration/prompts/specialized.md create mode 100644 apps/stokowski/priv/examples/exploration/workflow.yaml create mode 100644 apps/stokowski/priv/examples/feature/prompts/global.md create mode 100644 apps/stokowski/priv/examples/feature/prompts/phase.md create mode 100644 apps/stokowski/priv/examples/feature/prompts/specialized.md create mode 100644 apps/stokowski/priv/examples/feature/workflow.yaml create mode 100644 apps/stokowski/test/fixtures/histories/approval.term create mode 100644 apps/stokowski/test/fixtures/histories/default-eight-phase.term create mode 100644 apps/stokowski/test/fixtures/histories/escalation.term create mode 100644 apps/stokowski/test/fixtures/histories/external-terminal.term create mode 100644 apps/stokowski/test/fixtures/histories/rework.term create mode 100644 apps/stokowski/test/fixtures/histories/straight-through.term create mode 100644 apps/stokowski/test/stokowski/config_phase1_test.exs create mode 100644 apps/stokowski/test/stokowski/flow_phase1_test.exs create mode 100644 apps/stokowski/test/stokowski/phase1_core_test.exs create mode 100644 apps/stokowski/test/stokowski/prompt_report_tracking_phase1_test.exs create mode 100644 documents/decisions/0007-phase-1-deterministic-core.adoc diff --git a/apps/stokowski/lib/stokowski/config.ex b/apps/stokowski/lib/stokowski/config.ex new file mode 100644 index 0000000..72d5302 --- /dev/null +++ b/apps/stokowski/lib/stokowski/config.ex @@ -0,0 +1,788 @@ +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, Map.put(raw, "__phase_order__", declaration_order(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, raw, ""} + 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, Map.put(raw, "__phase_order__", declaration_order(workflow)), String.trim(body)} + end + + _ -> + {:error, + {:legacy_migration_required, + "WORKFLOW.md needs YAML front matter with a deterministic states graph"}} + end + end + + defp declaration_order(%Stokowski.Workflow{documents: [{:mapping, entries}]}) do + case find_mapping(entries, "states") do + {:mapping, state_entries} -> + Enum.map(state_entries, fn {name, _value} -> to_string(name) end) + + _ -> + [] + end + end + + defp declaration_order(_), do: [] + + 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 + |> 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", %{})) + + 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, 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", %{})) + } + + 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) + ), + workflow_raw when is_map(workflow_raw) <- Map.get(workflows, routed) do + {:ok, routed, workflow_raw, Map.put(decision, :workflow, routed)} + else + nil -> + {:error, {:unknown_workflow, requested}} + + {: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 = map_get(workflow_raw, "states", workflow_raw) + 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) + relative = Path.relative_to(candidate, Path.expand(workflow_dir)) + 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: [] +end diff --git a/apps/stokowski/lib/stokowski/domain.ex b/apps/stokowski/lib/stokowski/domain.ex new file mode 100644 index 0000000..7f77d5b --- /dev/null +++ b/apps/stokowski/lib/stokowski/domain.ex @@ -0,0 +1,339 @@ +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() | pos_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 + + @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() + } + 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(: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: normalize_event(Map.put(event, "type", type)) + + 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..9e3e653 --- /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) when state.status == :waiting 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..1af33fb --- /dev/null +++ b/apps/stokowski/lib/stokowski/phase_machine.ex @@ -0,0 +1,254 @@ +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, %Domain.Phase{type: :terminal}, state, %Domain.TerminalCompleted{ + reason: reason + }) do + {:ok, %{state | status: :completed, feedback: reason}, []} + 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) + when is_integer(phase.max_rework) and phase.max_rework > 0 and + state.run >= phase.max_rework do + failure = %Domain.Failure{ + class: :configuration, + reason: :max_rework_exceeded, + retryable: false, + detail: %{phase: phase.name, run: state.run, feedback: feedback} + } + + {:ok, %{state | status: :escalated, failure: failure, feedback: feedback}, + [{:escalate, %{failure: failure}}]} + end + + defp rework(snapshot, phase, state, feedback) do + 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, + 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 + + 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} + |> :erlang.term_to_binary([:deterministic]) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + + %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..9cc8ed8 --- /dev/null +++ b/apps/stokowski/lib/stokowski/prompt.ex @@ -0,0 +1,449 @@ +defmodule Stokowski.Prompt do + @moduledoc "Safe, deliberately small prompt renderer and lifecycle assembler." + + use Continuum.Pure + + alias Stokowski.Domain.{Issue, Phase, PhaseState, WorkflowSnapshot} + + @report_contract [ + "### 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." + ] + + @token ~r/(\{\{.*?\}\}|\{%.*?%\})/s + + @doc "Render the supported prompt subset without evaluating arbitrary code." + @spec render(binary(), map()) :: binary() + def render(template, context) when is_binary(template) and is_map(context) do + template + |> tokenize() + |> render_nodes(context) + |> List.flatten() + |> Enum.join() + end + + @doc "Build the nested and flat context accepted by legacy prompt examples." + @spec context(Issue.t() | map(), map()) :: map() + def context(issue, lifecycle \\ %{}) do + issue = issue_map(issue) + + issue_context = + issue + |> Enum.map(fn {key, value} -> {to_string(key), display_value(value)} end) + |> Map.new() + + flat = + issue_context + |> Enum.map(fn {key, value} -> {"issue_#{key}", value} end) + |> Map.new() + + Map.merge(%{"issue" => issue_context}, flat) + |> Map.merge(%{"lifecycle" => lifecycle}) + |> Map.merge(lifecycle) + end + + @doc "Assemble global prompts, the selected phase prompt, and lifecycle data." + @spec assemble(WorkflowSnapshot.t(), Issue.t() | map(), PhaseState.t() | binary(), keyword()) :: + binary() + def assemble( + %WorkflowSnapshot{prompts: prompts, graph: graph} = snapshot, + issue, + state, + opts \\ [] + ) do + phase_name = if is_binary(state), do: state, else: state.phase + phase = Map.get(graph, phase_name) + lifecycle = lifecycle_data(snapshot, issue, state, phase, opts) + ctx = context(issue, lifecycle) + + global = prompts |> Map.get(:global, Map.get(prompts, "global", [])) |> List.wrap() + + legacy = Map.get(prompts, :legacy, Map.get(prompts, "legacy", :unavailable)) + + legacy = + if is_binary(legacy) and legacy != "" do + [render(legacy, ctx)] + else + [] + end + + phase_prompt = + prompts |> Map.get(:phases, Map.get(prompts, "phases", %{})) |> Map.get(phase_name, "") + + (Enum.map(global, &render(&1, ctx)) ++ + legacy ++ + if(is_binary(phase_prompt) and phase_prompt != "", + do: [render(phase_prompt, ctx)], + else: [] + ) ++ + [lifecycle_markdown(lifecycle) || ""]) + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n\n") + end + + @doc "Build the deterministic lifecycle data used by every assembled prompt." + @spec lifecycle_data( + WorkflowSnapshot.t(), + Issue.t() | map(), + PhaseState.t() | binary(), + Phase.t() | nil, + keyword() + ) :: map() + def lifecycle_data(snapshot, issue, state, phase, opts \\ []) do + state = if is_binary(state), do: %PhaseState{phase: state}, else: state + comments = normalize_comments(Keyword.get(opts, :comments, [])) + max_comments = Keyword.get(opts, :max_comments, 20) + max_chars = Keyword.get(opts, :max_chars, 12_000) + selected = comments |> Enum.take(max_comments) |> comments_markdown() + selected = truncate(selected, max_chars) + + %{ + "phase" => state.phase, + "state" => state.phase, + "run" => state.run, + "attempt" => state.attempt, + "workflow" => snapshot.workflow, + "workflow_fingerprint" => snapshot.fingerprint, + "entry_phase" => snapshot.entry_phase, + "transitions" => state.transitions, + "available_transitions" => if(phase, do: phase.transitions, else: %{}), + "rework" => Keyword.get(opts, :rework, false), + "rework_feedback" => Keyword.get(opts, :rework_feedback, state.feedback), + "comments" => selected, + "comment_count" => length(comments), + "comments_truncated" => + length(comments) > max_comments or selected != comments_markdown(comments), + "issue" => issue_map(issue) + } + end + + @doc "Render the lifecycle section and the structured report contract." + def lifecycle_markdown(data) when is_map(data) do + issue = Map.get(data, "issue", %{}) + + lines = [ + "---", + "", + "", + "## 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: String.contains?(body, "/s + @marker ~r//s + @doc "Return the newest valid marker of a requested kind." @spec latest([map()], String.t()) :: {:ok, map()} | :none + def latest(comments, kind) when kind in [:state, :gate], + do: latest(comments, Atom.to_string(kind)) + def latest(comments, kind) when kind in ["state", "gate"] do comments - |> Enum.flat_map(&markers(&1, kind)) - |> Enum.max_by(& &1.timestamp, DateTime, fn -> nil end) + |> valid_markers() + |> Enum.filter(&(&1.kind == kind)) + |> Enum.max_by(&sort_key/1, fn -> nil end) |> case do nil -> :none marker -> {:ok, marker} end end - defp markers(comment, kind) do - body = comment["body"] || comment[:body] || "" + @doc "Decode every valid legacy or Fantasia v1 marker in stable order." + @spec markers([map()] | binary()) :: [map()] + def markers(body) when is_binary(body), do: decode_body(body) + def markers(comments) when is_list(comments), do: valid_markers(comments) + + @doc "Return the newest valid marker across state and gate entries." + def latest_marker(comments), + do: valid_markers(comments) |> Enum.max_by(&sort_key/1, fn -> nil end) + + @doc "Compatibility projection used by the tracker recovery boundary." + def parse_latest_tracking(comments) do + case latest_marker(comments) do + nil -> nil + %{kind: kind, payload: payload} -> Map.put(payload, "type", kind) + end + end + + @doc "Return the timestamp embedded in the newest valid tracking marker." + @spec latest_timestamp([map()]) :: DateTime.t() | nil + def latest_timestamp(comments) do + case latest_marker(comments) do + nil -> nil + marker -> marker.timestamp + end + end + + @doc "Compatibility helper returning the timestamp as an ISO string." + def get_last_tracking_timestamp(comments) do + case latest_timestamp(comments) do + nil -> nil + timestamp -> DateTime.to_iso8601(timestamp) + end + end + + @doc "Return attributed, non-machine comments after a tracking timestamp." + def recent_comments(comments, timestamp \\ nil, opts \\ []) do + cutoff = parse_timestamp(timestamp) + limit = Keyword.get(opts, :limit, :infinity) + + comments + |> Enum.reject(&machine_comment?/1) + |> Enum.map(&normalize_comment/1) + |> Enum.filter(fn comment -> after_cutoff?(comment.created_at, cutoff) end) + |> Enum.sort_by(&{timestamp_key(&1.created_at), &1.id, &1.body}) + |> take_limit(limit) + end + + @doc "Legacy-compatible alias for recent comment projection." + def comments_since(comments, timestamp), do: recent_comments(comments, timestamp) + + @doc "Phase 0-compatible name for the recent attributed comment projection." + def get_comments_since(comments, timestamp), do: recent_comments(comments, timestamp) + + @doc "Encode a Fantasia v1 state marker using caller-supplied time and identity." + def write_state(state, timestamp, effect_id, opts \\ []) do + with {:ok, timestamp} <- normalize_timestamp(timestamp), + :ok <- valid_text(state, :state), + :ok <- valid_text(effect_id, :effect_id), + :ok <- valid_run(Keyword.get(opts, :run, 1)) do + payload = %{ + "schema" => 1, + "state" => to_string(state), + "run" => Keyword.get(opts, :run, 1), + "timestamp" => timestamp, + "effect_id" => to_string(effect_id) + } + + payload = + if Keyword.has_key?(opts, :workflow), + do: Map.put(payload, "workflow", Keyword.get(opts, :workflow)), + else: payload + + {:ok, encode_marker("state", payload)} + end + end + + @doc "Encode a Fantasia v1 gate marker using caller-supplied time and identity." + def write_gate(state, status, timestamp, effect_id, opts \\ []) do + with {:ok, timestamp} <- normalize_timestamp(timestamp), + :ok <- valid_text(state, :state), + :ok <- valid_gate_status(status), + :ok <- valid_text(effect_id, :effect_id), + :ok <- valid_run(Keyword.get(opts, :run, 1)) do + payload = %{ + "schema" => 1, + "state" => to_string(state), + "status" => to_string(status), + "run" => Keyword.get(opts, :run, 1), + "timestamp" => timestamp, + "effect_id" => to_string(effect_id) + } + payload = + if Keyword.has_key?(opts, :rework_to), + do: Map.put(payload, "rework_to", Keyword.get(opts, :rework_to)), + else: payload + + {:ok, encode_marker("gate", payload)} + end + end + + @doc "Convenient state writer; unlike the Phase 0 writer it never reads a clock." + def state_comment(state, timestamp, effect_id, opts \\ []), + do: write_state(state, timestamp, effect_id, opts) + + @doc "Convenient gate writer; unlike the Phase 0 writer it never reads a clock." + def gate_comment(state, status, timestamp, effect_id, opts \\ []), + do: write_gate(state, status, timestamp, effect_id, opts) + + @doc "Encode a marker payload when the caller already built the structured data." + def encode(kind, payload) when kind in [:state, :gate, "state", "gate"] and is_map(payload) do + kind = to_string(kind) + {:ok, encode_marker(kind, Map.put_new(payload, "schema", 1))} + end + + # These names make migration call sites explicit and are intentionally pure. + def make_state_comment(state, opts) when is_list(opts) do + write_state(state, Keyword.get(opts, :timestamp), Keyword.get(opts, :effect_id), opts) + end + + def make_gate_comment(state, status, opts) when is_list(opts) do + write_gate(state, status, Keyword.get(opts, :timestamp), Keyword.get(opts, :effect_id), opts) + end + + def make_state_comment(_state, _run, _workflow), do: {:error, :timestamp_required} + + defp valid_markers(comments) do + comments + |> stable_comments() + |> Enum.flat_map(fn {comment, comment_order} -> + comment + |> body_of() + |> decode_body() + |> Enum.with_index() + |> Enum.map(fn {marker, marker_order} -> + %{marker | order: {comment_order, marker_order}} + end) + end) + end + + defp stable_comments(comments) do + comments + |> Enum.map(&normalize_comment/1) + |> Enum.sort_by(fn comment -> + {timestamp_key(comment.created_at), comment.id, comment.body} + end) + |> Enum.with_index() + end + + defp decode_body(body) do Regex.scan(@marker, body, capture: :all_names) - |> Enum.flat_map(fn [json, found_kind] -> - with true <- found_kind == kind, - {:ok, payload} <- Jason.decode(json), - timestamp when is_binary(timestamp) <- payload["timestamp"], - {:ok, parsed, _offset} <- DateTime.from_iso8601(timestamp) do - [%{kind: found_kind, payload: payload, timestamp: parsed}] + |> Enum.with_index() + |> Enum.flat_map(fn {[json, kind, namespace], index} -> + namespace = String.trim_trailing(namespace, ":") + + with {:ok, payload} <- Jason.decode(json), + {:ok, timestamp} <- embedded_timestamp(payload), + :ok <- validate_payload(payload, kind, namespace) do + [ + %{ + kind: kind, + namespace: namespace, + payload: normalize_payload(payload), + timestamp: timestamp, + order: {0, index} + } + ] else _ -> [] end end) end + + defp embedded_timestamp(%{"timestamp" => timestamp}) when is_binary(timestamp) do + case DateTime.from_iso8601(timestamp) do + {:ok, parsed, _offset} -> {:ok, parsed} + _ -> {:error, :invalid_timestamp} + end + end + + defp embedded_timestamp(_), do: {:error, :missing_timestamp} + + defp validate_payload(payload, kind, namespace) do + schema_ok = + case {namespace, Map.get(payload, "schema")} do + {"fantasia:v1", 1} -> true + # Early Phase 1 markers were emitted before the schema field was + # required. Keep reading them while all new writes remain versioned. + {"fantasia:v1", nil} -> true + {"fantasia:v1", _} -> false + {"stokowski", _} -> true + _ -> false + end + + required = + case kind do + "state" -> + is_binary(Map.get(payload, "state")) and Map.get(payload, "state") != "" + + "gate" -> + is_binary(Map.get(payload, "state")) and Map.get(payload, "state") != "" and + Map.get(payload, "status") in ~w(waiting approved rework escalated) + + _ -> + false + end + + if schema_ok and required, do: :ok, else: {:error, :invalid_payload} + end + + defp normalize_payload(payload) do + payload + |> Map.put_new("run", 1) + |> Map.put_new("schema", 1) + end + + defp sort_key(%{timestamp: timestamp, order: order}), + do: {DateTime.to_unix(timestamp, :microsecond), order} + + defp body_of(%{body: body}) when is_binary(body), do: body + defp body_of(%{"body" => body}) when is_binary(body), do: body + defp body_of(_), do: "" + + defp normalize_comment(comment) do + %{ + id: to_string(Map.get(comment, :id, Map.get(comment, "id", ""))), + body: body_of(comment), + created_at: + parse_timestamp( + Map.get( + comment, + :created_at, + Map.get( + comment, + :createdAt, + Map.get(comment, "createdAt", Map.get(comment, "created_at")) + ) + ) + ), + author: author(comment) + } + end + + defp author(comment) do + 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")) + + cond do + is_map(actor) -> + to_string( + Map.get( + actor, + "displayName", + Map.get( + actor, + :displayName, + Map.get(actor, "name", Map.get(actor, :name, "unknown author")) + ) + ) + ) + + is_binary(actor) and actor != "" -> + actor + + true -> + "unknown author" + end + end + + defp machine_comment?(comment), + do: + String.contains?(body_of(comment), "" + + 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/approval.term b/apps/stokowski/test/fixtures/histories/approval.term new file mode 100644 index 0000000000000000000000000000000000000000..4823750dc7d361e5647e59faef8aed0f8772504e GIT binary patch literal 3097 zcmds3OOM+&5MIC5&hBClO>->TLs9g!(8Hpn9D)Q*nrqPNT1?bg&ANmUyey2Lx2a2f-*ID? zc&^KN7%&ns7DcJgdN>QDowo*YKEEz--)8eTf?^ zm9&|Rt!1Jw8?38F>1*S510shsM{AVp{J?mXBvLhNs**(i^ep`MRRN;Bd$%&*|D`JG z92M@I_AmZ;>f%RG_#UZ{qAK-*BeE3e!!WEFP*pWU+k$nM=1|v7wZVHm6H=}XJ^hpx zvV>Y@m9lAH>)EP-I@>~Ek)E=uMEmbXPfI9pZ_{&$avRfJlo>Wc?YLhkYzk3At}l(( zqW%h4-<`_&w+y5OdtH`BErW2cRRg6I%Ag@_To?L9gSu)S5>*Y~yei;J(w+?uZgNo~ zG=?+-?eJ(;R1EW>?b7J@PzV+VqlJw?eE`nRdJ>-L$~yf|pc&aHJMy21d%|mLv)Jh# zRxs9Q0%&SKeE`qab`qQ!*edDJ>4n#;uycrJi_F3_p=~uW zJ-C$_cma96KQ|;ak4bH-@QH+ZQ>~S`*jC-Gq!|{FY_PFi07m+jRwWm!p5=x@4XMaY zwc;+I8_*zBBE`yfh#7}mZre*YIleKSOl*XDOgeU0J3vNyQgFy7`iEx#*#U9)uDxd* z=>-$A21PU5%||xext=mG)ovaqyFsF7`}8{Q=b#iC>`X3W!EE9m+Y$Dfy2xTbC5|7_ zG)NHwCyabTAwonqb%B!53>nI49CANq7@K+H5I=^Lk^sqo;gciccl^8-4YCjI+-wKt zJ{dh$m5r&3cFGOWJ$81%x9PUSa^Uv+XSj8fsm=NrAV(E;Y>4!r`?ix@@^zKnA8Y}O zj(L4@1(+L!$#sD+gEuzwx69CvV#bzf5PRHZ9-|llx=YUo!d=3nGzvWCdy$8pL*3Bl zF_4f&-~%`@A-y%gY#8He+pWgCRpkl!7p)A;wsKBUSf>6~_Ga%ni|j`|dbczEP~suR zZk(om$UI1S>csvM18M*y6vLFJW^*DKrw;Qm3^C;pFJ0er49tAFG)NA#_75fAm3k-f mHo(2WJH>Y*JBP$u7sp%Z|4HDZ{hoV7;CtMU3;f?o;{Go|A{CAR literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ddf5ab4bf6fdb4e5ce3afce33064c4554d088386 GIT binary patch literal 11726 zcmeHN-EQN!70!t z1_Ko6EA%<~9(|2INe?C4k>qF_)J~_E`X-S{kvu#+-}(8dza}RqC*u+j3#F4M)Gx_*~r zdUmGI^JPj;>Cfl5&Ku?s&$FG_A+F;{83uwxLPQbsgl9u21oz{RGsh-8fCwTP5aLQg zeB$vyMiI(0TF_OJQDNjB=nJ@qv@Fa{MyBOlU*xdJ@={;oviDRalqFyqi)GSjoJ^-h znJ;-co$BB2i2qeKrz*XEdxP`;A)7A}C}F*5bKr0K27W_J_zJixRF>+o1Q3k!yS7+E z%F8Tow~fVS(1RR7N|Lh??2jH4Lc%g1$hDoG(f<=pTR1@;fU_ zlNJ84${AREndkVDwV2TYUR&?qTa}R4=v2~DNd>v};e++hzx?g7t5jtI*m^ygy#K;d zay>#o!E?1J5j)lwA0~e^d$k&kuP58d8?88xnFW#p%&0$Q%^{Yig=O%B(unKoy;R_W z_@ZuF)-U@2{jJ#*dXiQ&njSbG8MCm6pmYVg^|hSac%ejN*_8;@uIG6_c=)<0IF(MoqZ=ich;J^qjB zG1r#)sJ#D%uk3=gqqT1L|D@s?@uaQ1mbY()n)5@1npXb)yT1J0lq(WBiSO4%5;6&S zc8g4h(e$}KQ^FkcPbE!DS}6J@JW!JoK2}M)UCs3OSAFY#Tdz_rhow_hARtX~lVpr0 zhPbFA_TPVPy<4wrncBceI+?u768v8kS5+xxVfBW^6bB1g7|SpnSaoFi%2HT(7%Ib5 zAlKG+6$N4~@x{DE7NcBE;9-#{j@8zx8kzvThicw>+%kuHzP!X~e^_GF#s;>kUP%-Z zLMx7i8qd-a9?I*yc|+a-K@dut;0)7xF`;Xkc? z>G8&In0<`|NLo>zQl&SLFLHDPrsEB`B#Z(Ykr14S#)2{uNFa_+e3yAZ1K>;w>Vp@9 zi~MX%T;Ik=9)v7pcAzh@NX}Q&3hHLYrm5&ro-IpUv39CkDF*1BKo60~>v9!OOZ{2F zXE3MJd&mtJMNjo-=zFBPX|!B%x}Z!Y$h)AsLP1XS71dOg(?Z=C%3>%fQbDF?Lva5L z30HNm%GGp3&WM`lKGo-QNoVKU+HvEC$G(1BkHdxZSg2w_OFpZ7x0!vU2ZACqgOBHH zzFQBs>e-Flv@8K50d8s?biS!6_t$}MONb)Q1Ij{>u~9g+B>`JTA!Ba9!Wfxk>IAWL zVxLRmcmY1g+{Ke2&54|WB!e>G3N=LYYLqNgtU5^s!5E-#3aE0eaF1R*#LQlq&gd(CIrFKk6<0b?z zSUlrae5p}X@O;m1K7V`nIwP&d&}V?m2|BmKE;_riQ><80`RTvz0?Ysj=WD z%{_u>7euGq>%{*6M0?uTvp|IL9Y3^vJHXet&t1$xxNSq^QSRD@1XPdkTW~e)|9}yyXW1jf`s38uJ`mf};+_Su z!OKCcMZIs_`)=9?m;u-^BSO$94uo_g-=zYx0|pqaq(_M3GB=LA$Tq>uH7Q{rg&%SZ zXaLS3fz=Xu3#UH2KOnSP3m*a152*7!0JX2BJqu97mjhIXJMIlt08&VB0!*P1Nw!N# z;4_o=7g)|@m)?G2RF_c?+qwy&n)r?e&gG>POOG zA0+K*Vb4O+#mgaSbNki{A#QsdZ0h5Oc)l00P&yvinCba~#mFG}G>biIrOMm0t*W^MJgm^#OMCw zU5KJ2+LB|}ZaYougGUBMWB0e;K7ik|gM)*kntl!KpuU=;J+6$x32Ke9w*3k}z=0iH zm|WSxqQcTXmBnlZxtbU?J?eh@>o=MCXv)*~b#YPH>cX79%?dm1*?zgm;SiqBXrGs< z&l&Ul#OHzUYaK_53#EienPMKsO35&ZW$JreMkr9|i1R>m9&#olEd<#!n!{CAfO2*Z z>=9mKURTbOfm`X@qY~#uS=(dU_7Y46X@+jGT4dK2pH3!KT`pujnb;q0nE$$%fyqx_ zozegQxtPr}tZ}vJ_KV-|d+{CA_!_0DOp)8MMpP-t3#UTA$k7f#)8l>=vy=R|(T+~c~0yfQUKwsLkI z*e6ZACUw#M=F=H`oHWn64^A?ZBa{RnVbXmxoD~YQ?%EOM48+N$L3>S{)#_e( z24`;5zeSouXXOay6o$*Kz-{ze2Gg-_x3q$ueI$VfZ5nN}v)pbUJ40|8->S1^-d44E z3}}|hjH#e5E6Y|e{}Gt%c1`=e@ig8(-yJpeJ54TE5x5|yNliR(u zL}6+ws(V-VSeyI|%Xw+?+C6wyO(BT*i}iB~LU}cryDZ!UVN@22nqF+O?pY~zBSU&wA3q>O~1DqyEe){X)vJ4>oK8l zk@_U|G{Zd-_A7jrMcJ<5X=%DSBwb4ZB(+2+(vYdZk3%6Nr>bC)&?2PIVIW27A=7CP zusDr;8F?H+#8Am72GwEleknjUF1yC<9<&HPMglY%3-BA=cO(B$B<_U#;IBizE%j}o+%@I| zp%>~%$Ei+z83qD;rRiD#!-R#Y3X+Hgk)jzV^qHSTLMA*-1Za;7^mW>y?|wCYBmTX| zoIefyT9D6v%;`S>d+9$6iaTLHd?f6tp=r8zT9m6p1%((Yx&wN?xeJ)!M@bh#*quoB z*2=fdzq3aQYx?NCSsfv^+ewR3cZ^!z%*YI?%6`!tjus`jn+tc$q!ZTidaOAET|aTq zSnJCm)mkx516QhrC!$1pbde=uI$9tAPgoq$M8*Q4>G;W0$KbYfT&usk3q2H!^FILO)OmaW literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ad7cc0736064ded5431f3a7fede0b27016c1111c GIT binary patch literal 2200 zcmc&$OK;;g5cWfUY!c+q$FXQnMSBVKuxNP?#V*=yuSK^&kHSbCT1+HTOVVb1?jJAd zM^6mw(@OJm0xKf%BT4_r_iY&0r+ zRm!FTL32*4Y5(-sA8P){)!j!UAEea}{O;FU+G=FSY9n9>Up6kzyCNb<5=N_t#8Jf9 zGNmM=G|P&FCLt~WLWm)F%%v#vh5~#-=*ePyN80|9| z`jwHLDg-Tso#UfHC7`xfd9IDxlqS#ZkI!KKA{*f1?oU(=cc*`rOy0DD z;okzUp{UW$wSZPv()eK4cCuE`R{r>%mzgM8q<-HNYY1D&zZ4?O2jf<>3u7Q1{&`R}?8`(d3)xcwZ z*z0fH)LbAaMNX`V-uJ%vtbl`RB{WXea&&=Gl{=P9fV*W41 z)NAJ-F7?D;j!fnZ=;N_nooy(2?`^HuAAZ#}-LW_3UIKq>UkVl1Myj2?W?ZaMwTcVl zH*R$WaZ27k?K=_FE^mDoeimV-vosx@C7>^aq83j?Jy?Z0?D#4$pDfB*)n4xu1K2ED21>Km2nojVGD)%s3D9N8dU}5U4DQjM|G-Zhu8@8>%%;OonwB6+XF#KKGYgoL zwW9?XgHC3j(d;;odSSo46!!h$qMe+p`(ib{G%#kz*iQ1VPUV&Vdqv%!&Dr@$WqWbR z&RjL=O@lCjpM2i$7Rw}CQMxG7Rm>t9(-Kp_Xb~qsSVUM>WNA#3IEyh3%V?Rf6_6#( iAOUb{kb0`7#HSn4RasLlI9qbGM literal 0 HcmV?d00001 diff --git a/apps/stokowski/test/fixtures/histories/rework.term b/apps/stokowski/test/fixtures/histories/rework.term new file mode 100644 index 0000000000000000000000000000000000000000..4066a03dd3e4acb1e2386ca8ccc8d449ae5fa7f7 GIT binary patch literal 5586 zcmd^@O>^5e5Qc4iSW4{4p&!Rid+2m}@{j}x0y%V&Y0_Lf?V(4_0$5nEB~l?NBR=v?Vu=)Xp@`!IOYuu>0<_yRY@dv4x8;qw{AHHgY0vhHWd^74<=ox# zN`#b`M-m?|Y>o=Oo0R3^xf>4efqQyNnu6U~^5XS9I3Sq{p_ zJ+NnZi&<6rOa}g>Z_f%`{ufz0rJMH(AVaJ9fGX#q8sH{HJ^9! z+Pt{=FJ#nB;qkOauvirNEneH9Da)k;u3e;YnZcr*=9N$T+zzh_SWH(Ec-00f&(Qzt z!VWT+JOomW63>_@pN5grpaMnFLbDen~Z7+kP z<<_9RC(inGuRMb*f9by>&8d&FDgL%_TfJ6io_O2R3VQZP0u7pGYO}MtZXY{C@O|H? z(+aOOGmZhxN|~`6tE>6q?`g`m6{ib9OQ7z9BWPvYlkLZSJ@JRDlXk=X_jS9vkzC~0 zer0s}!=JjQTldD;%kAItIgX~Lbkpz7o@*PYh0)&~lZpC##7hu6ulhR!I2{F_# zD_7&C*@a)Y0c~zgbvJ3Mqw1A-YWs5y)4u)gfk1xcUB0g0a~IjMGUWm)IraPL)Q3B= z0|ldi{5<{q})w&91Gp-X+LR!gei@mULe=$W0!n?E0Rs88?-;dri&A5mqke2t)YE zXZ|Wq3FAt|BH@t^Riq>)fMFaFpgN?Q36?~PL@dH6kYP$R2bwAd1R&s)wv3JQOuc-y z`g?2hwN?IEje+xD$ArRH>Tj`kPVPw9ukl$8WxIi=q3NMjy4M1Mbm9e)3M2|qD5oV0 zA`vh~(l`nU6OjZ=Vy;z$oH7|v&P7B55-W5K(P8mvE#P8YZH>n>Xb?O`1Jokz9iH|~l z@XsONjQVC!?y@;i9K<@&sn8;nal{~0+I=~IF(Cq@|H(GzlcC=T^4Z&*{sGv_@GvMo3j5)aus>~&c>mSKeJ3v@OJd+Lgn{#m zI*oW7#sLxuL7c>jJO4;UoKj~Yoqq|50Tej|@#Z(0#>ua~TX|^3n*MAV ZnNLQ%zT)#YGK0qe-{`)3t{;lT^3a>0OIvtr)=^DaBHoslv}NGP8TeiJ5f@rMW6a z(*P$qBh_NK`{jq4ePQzUlNR^FsC#z%S1n96F;m%b7{kwv&GWYK;vm>~nHT$>M`;p~ zI3*-ai+}_hOb98%EGY|b6PFRDn9?YYeHzDM9F!4FQ(MongFq1Up;eX8u7b6hZzZ&q`<}R_XGCyx|E|r9LxZQ7St?YD32v5*S16f2(6G`d z8^)nFi&omy->Li6rMf>>pj_V_=a#rZxoa(fE2eE`^(Iavd%!rnHbdv!;x1 zQMQtC?G|oS1%4F2Ijvg?Bp-S&?oIflM>El4 z5B$Es8MTx?;Q_QpOR?)_d9dF>(qEq;bo9FZ;4=iit)y=`;qs8%(*4U(TKV_YbaysY zR!4@-#gVeKN>W{eFo!=}-*3YtNHaphBFcQ~5ucP80eYbyfWMwc;*y;Bp1#$NkI01{# literal 0 HcmV?d00001 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..49cbda1 --- /dev/null +++ b/apps/stokowski/test/stokowski/config_phase1_test.exs @@ -0,0 +1,119 @@ +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 "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 "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..87fa14a --- /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", 2}, + "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..bd60aca --- /dev/null +++ b/apps/stokowski/test/stokowski/phase1_core_test.exs @@ -0,0 +1,260 @@ +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} + assert {:ok, work, effects} = PhaseMachine.reduce(snapshot, gate, Domain.rework("fix this")) + assert work.phase == "work" + assert work.run == 2 + assert Enum.map(effects, & &1.kind) == [:rework, :dispatch] + + ceiling = %{gate | run: 2} + + assert {:ok, escalated, [%Domain.RequiredEffect{kind: :escalate}]} = + PhaseMachine.reduce(snapshot, ceiling, Domain.rework("again")) + + assert escalated.status == :escalated + 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") + ] + } + + assert {:ok, escalation_id} = Continuum.Test.start_synchronous(Stokowski.Flow, escalation) + + assert {:ok, + %{state: :completed, result: {:ok, %{state: %Domain.PhaseState{status: :escalated}}}}} = + 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 + + 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..bf5888b --- /dev/null +++ b/apps/stokowski/test/stokowski/prompt_report_tracking_phase1_test.exs @@ -0,0 +1,119 @@ +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"} + ] + ) + + 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") + 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 "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"} + } + ] + + assert {:ok, marker} = Tracking.latest(comments, "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 +end diff --git a/config/config.exs b/config/config.exs index fd756a3..7b2590e 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: [:erlang, :crypto] + # 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..d6013d2 --- /dev/null +++ b/documents/decisions/0007-phase-1-deterministic-core.adoc @@ -0,0 +1,41 @@ += 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. From dea28e1393f5c9faff78c04b9ccb21be015ed9c8 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Tue, 15 Sep 2026 10:24:08 -0400 Subject: [PATCH 2/2] fix(stokowski): harden phase one deterministic core --- .../lib/mix/tasks/phase1.histories.ex | 157 ++++++++++++++++++ apps/stokowski/lib/stokowski/config.ex | 133 ++++++++++++--- apps/stokowski/lib/stokowski/deterministic.ex | 12 ++ apps/stokowski/lib/stokowski/domain.ex | 16 +- apps/stokowski/lib/stokowski/flow.ex | 2 +- apps/stokowski/lib/stokowski/phase_machine.ex | 91 +++++----- apps/stokowski/lib/stokowski/prompt.ex | 46 ++--- apps/stokowski/lib/stokowski/report.ex | 15 +- apps/stokowski/lib/stokowski/tracking.ex | 15 +- .../test/fixtures/histories/README.md | 21 +++ .../histories/default-eight-phase.term | Bin 11726 -> 11726 bytes .../test/fixtures/histories/escalation.term | Bin 5754 -> 8270 bytes .../test/stokowski/config_phase1_test.exs | 106 ++++++++++++ .../test/stokowski/flow_phase1_test.exs | 2 +- .../test/stokowski/phase1_core_test.exs | 76 ++++++++- .../prompt_report_tracking_phase1_test.exs | 86 +++++++++- config/config.exs | 2 +- .../0007-phase-1-deterministic-core.adoc | 31 ++++ 18 files changed, 690 insertions(+), 121 deletions(-) create mode 100644 apps/repo_management/lib/mix/tasks/phase1.histories.ex create mode 100644 apps/stokowski/lib/stokowski/deterministic.ex create mode 100644 apps/stokowski/test/fixtures/histories/README.md 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 index 72d5302..ff96c3b 100644 --- a/apps/stokowski/lib/stokowski/config.ex +++ b/apps/stokowski/lib/stokowski/config.ex @@ -26,7 +26,7 @@ defmodule Stokowski.Config do if Path.extname(path) in [".yaml", ".yml"] do with {:ok, workflow} <- Stokowski.Workflow.read(path), {:ok, raw} <- Stokowski.Workflow.normalize(workflow) do - {:ok, Map.put(raw, "__phase_order__", declaration_order(workflow)), ""} + {:ok, with_loader_metadata(raw, workflow), ""} end else parse_markdown(content) @@ -41,7 +41,7 @@ defmodule Stokowski.Config do else with {:ok, workflow} <- Stokowski.Workflow.parse(content), {:ok, raw} <- Stokowski.Workflow.normalize(workflow) do - {:ok, raw, ""} + {:ok, with_loader_metadata(raw, workflow), ""} end end end @@ -51,7 +51,7 @@ defmodule Stokowski.Config do [front_matter, body] -> with {:ok, workflow} <- Stokowski.Workflow.parse(front_matter), {:ok, raw} <- Stokowski.Workflow.normalize(workflow) do - {:ok, Map.put(raw, "__phase_order__", declaration_order(workflow)), String.trim(body)} + {:ok, with_loader_metadata(raw, workflow), String.trim(body)} end _ -> @@ -61,17 +61,61 @@ defmodule Stokowski.Config do end end - defp declaration_order(%Stokowski.Workflow{documents: [{:mapping, entries}]}) do - case find_mapping(entries, "states") do - {:mapping, state_entries} -> - Enum.map(state_entries, fn {name, _value} -> to_string(name) 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) end - defp declaration_order(_), do: [] + 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) @@ -174,6 +218,7 @@ defmodule Stokowski.Config do def fingerprint(data) when is_map(data) do data + |> fingerprint_data() |> canonicalize() |> :erlang.term_to_binary([:deterministic]) |> then(&:crypto.hash(:sha256, &1)) @@ -250,6 +295,9 @@ defmodule Stokowski.Config 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 @@ -312,7 +360,7 @@ defmodule Stokowski.Config do |> 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, normalized) + Map.put(acc, name, external_workflow(normalized)) else _ -> acc end @@ -327,7 +375,8 @@ defmodule Stokowski.Config do when is_map(states) and map_size(states) > 0 do workflow = %{ "states" => states, - "prompts" => map_get(project, "prompts", map_get(raw, "prompts", %{})) + "prompts" => map_get(project, "prompts", map_get(raw, "prompts", %{})), + "__phase_order__" => map_get(raw, "__phase_order__", []) } Map.put_new(workflows, "default", workflow) @@ -342,13 +391,15 @@ defmodule Stokowski.Config do if(requested, do: {:ok, to_string(requested), %{default: false, label: :explicit}}, else: route(map_get(project, "routing", map_get(raw, "routing", %{})), labels) - ), - workflow_raw when is_map(workflow_raw) <- Map.get(workflows, routed) do - {:ok, routed, workflow_raw, Map.put(decision, :workflow, routed)} - else - nil -> - {:error, {:unknown_workflow, requested}} + ) 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) @@ -360,7 +411,12 @@ defmodule Stokowski.Config do end defp build_graph(workflow_raw) do - raw_states = map_get(workflow_raw, "states", workflow_raw) + 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) @@ -487,7 +543,8 @@ defmodule Stokowski.Config do _ -> candidate = Path.expand(path, workflow_dir) - relative = Path.relative_to(candidate, Path.expand(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, "../") @@ -729,11 +786,11 @@ defmodule Stokowski.Config do 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_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 + {int, ""} when int >= 0 -> int _ -> :unavailable end end @@ -785,4 +842,38 @@ defmodule Stokowski.Config do 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 index 7f77d5b..d26b1c4 100644 --- a/apps/stokowski/lib/stokowski/domain.ex +++ b/apps/stokowski/lib/stokowski/domain.ex @@ -92,7 +92,7 @@ defmodule Stokowski.Domain do session: :inherit | :handoff | :fresh, transitions: %{optional(binary()) => binary()}, rework_to: Stokowski.Domain.unavailable() | binary(), - max_rework: Stokowski.Domain.unavailable() | pos_integer() + max_rework: Stokowski.Domain.unavailable() | non_neg_integer() } end @@ -162,7 +162,8 @@ defmodule Stokowski.Domain do status: :running, transitions: [], feedback: :unavailable, - failure: :unavailable + failure: :unavailable, + rework_counts: %{} @type t :: %__MODULE__{ phase: binary(), @@ -171,7 +172,8 @@ defmodule Stokowski.Domain do status: :running | :waiting | :completed | :failed | :cancelled | :escalated, transitions: [map()], feedback: Stokowski.Domain.unavailable() | binary(), - failure: Stokowski.Domain.unavailable() | term() + failure: Stokowski.Domain.unavailable() | term(), + rework_counts: %{optional(binary()) => non_neg_integer()} } end @@ -311,6 +313,7 @@ defmodule Stokowski.Domain do 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 @@ -325,8 +328,11 @@ defmodule Stokowski.Domain do end end - def normalize_event(%{type: type} = event), - do: normalize_event(Map.put(event, "type", type)) + 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}} diff --git a/apps/stokowski/lib/stokowski/flow.ex b/apps/stokowski/lib/stokowski/flow.ex index 9e3e653..61ac34a 100644 --- a/apps/stokowski/lib/stokowski/flow.ex +++ b/apps/stokowski/lib/stokowski/flow.ex @@ -45,7 +45,7 @@ defmodule Stokowski.Flow do when status in [:completed, :cancelled, :failed, :escalated], do: result(snapshot, state) - defp consume(snapshot, state, [], true, issue) when state.status == :waiting do + defp consume(snapshot, state, [], true, issue) do event = await(signal(:phase_event)) consume(snapshot, state, [event], true, issue) end diff --git a/apps/stokowski/lib/stokowski/phase_machine.ex b/apps/stokowski/lib/stokowski/phase_machine.ex index 1af33fb..5fda66c 100644 --- a/apps/stokowski/lib/stokowski/phase_machine.ex +++ b/apps/stokowski/lib/stokowski/phase_machine.ex @@ -45,12 +45,6 @@ defmodule Stokowski.PhaseMachine do |> List.first() end - defp reduce_event(_snapshot, %Domain.Phase{type: :terminal}, state, %Domain.TerminalCompleted{ - reason: reason - }) do - {:ok, %{state | status: :completed, feedback: reason}, []} - end - defp reduce_event(_snapshot, _phase, state, %Domain.TerminalCompleted{reason: reason}) do {:ok, %{state | status: :cancelled, feedback: reason}, [{:cancel, %{reason: reason}}]} end @@ -119,46 +113,57 @@ defmodule Stokowski.PhaseMachine do end end - defp rework(_snapshot, phase, state, feedback) - when is_integer(phase.max_rework) and phase.max_rework > 0 and - state.run >= phase.max_rework do - failure = %Domain.Failure{ - class: :configuration, - reason: :max_rework_exceeded, - retryable: false, - detail: %{phase: phase.name, run: state.run, feedback: feedback} - } - - {:ok, %{state | status: :escalated, failure: failure, feedback: feedback}, - [{:escalate, %{failure: failure}}]} - end - defp rework(snapshot, phase, state, feedback) do - 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, - transitions: state.transitions ++ [%{from: phase.name, event: :rework, to: target}] - } + 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, next, - [ - {:rework, %{from: phase.name, to: target, feedback: feedback}}, - {:dispatch, %{phase: target, run: next.run, attempt: next.attempt}} - ]} - end + {: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 @@ -176,9 +181,7 @@ defmodule Stokowski.PhaseMachine do id = {attempt, index, kind, data} - |> :erlang.term_to_binary([:deterministic]) - |> then(&:crypto.hash(:sha256, &1)) - |> Base.encode16(case: :lower) + |> Stokowski.Deterministic.hash() %Domain.RequiredEffect{ id: "effect:#{id}", diff --git a/apps/stokowski/lib/stokowski/prompt.ex b/apps/stokowski/lib/stokowski/prompt.ex index 9cc8ed8..c5a0c99 100644 --- a/apps/stokowski/lib/stokowski/prompt.ex +++ b/apps/stokowski/lib/stokowski/prompt.ex @@ -4,36 +4,10 @@ defmodule Stokowski.Prompt do use Continuum.Pure alias Stokowski.Domain.{Issue, Phase, PhaseState, WorkflowSnapshot} + alias Stokowski.Report - @report_contract [ - "### 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." - ] + @report_contract Report.contract() + @machine_marker ~r//s + @machine_marker ~r/), + createdAt: "2026-01-03T00:00:00Z" + } ] ) @@ -66,6 +72,7 @@ defmodule Stokowski.PromptReportTrackingPhase1Test do 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 @@ -80,6 +87,62 @@ defmodule Stokowski.PromptReportTrackingPhase1Test do 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 = [ %{ @@ -98,10 +161,16 @@ defmodule Stokowski.PromptReportTrackingPhase1Test do "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(comments, "state") + 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") @@ -116,4 +185,17 @@ defmodule Stokowski.PromptReportTrackingPhase1Test do 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 7b2590e..22239b7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -9,7 +9,7 @@ # move said applications out of the umbrella. import Config -config :continuum, trusted_modules: [:erlang, :crypto] +config :continuum, trusted_modules: [:crypto, Stokowski.Deterministic] # Sample configuration: # diff --git a/documents/decisions/0007-phase-1-deterministic-core.adoc b/documents/decisions/0007-phase-1-deterministic-core.adoc index d6013d2..7539866 100644 --- a/documents/decisions/0007-phase-1-deterministic-core.adoc +++ b/documents/decisions/0007-phase-1-deterministic-core.adoc @@ -39,3 +39,34 @@ 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.