From e0771fb608a79c00d22864fb551675f2fcaeb89a Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Mon, 25 May 2026 16:55:17 +0900 Subject: [PATCH 1/7] docs(plugin-dispatch): rescope around SSOT #25 and 5-PR plan Re-anchor the userlevel plugin dispatch document on the orchestration SSOT (#25) and docs/product-vision.md, replacing the implementation phasing section with the five-PR breakdown that this document drives. Notes the existing read-only slash-command CapabilityPreflight as the sibling layer that already handles / input, and scopes this document to the ooo-prefixed dispatch path that is still missing. Records the deferred vision issues (#19, #22, #24) with their trigger conditions so they stay open without blocking implementation work. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/userlevel-plugin-dispatch.md | 226 ++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/userlevel-plugin-dispatch.md diff --git a/docs/userlevel-plugin-dispatch.md b/docs/userlevel-plugin-dispatch.md new file mode 100644 index 0000000..3f4461b --- /dev/null +++ b/docs/userlevel-plugin-dispatch.md @@ -0,0 +1,226 @@ +# UserLevel Plugin Dispatch in Ourocode + +Tracks the operational plan for adding `ooo ...` dispatch to +the interactive terminal. The product boundary lives in `docs/product-vision.md` +and SSOT issue +[#25](https://github.com/Q00/ourocode/issues/25); this document is one vertical +slice under that SSOT and is intentionally scoped to the UserLevel plugin +dispatch path. + +Originating request: [#2](https://github.com/Q00/ourocode/issues/2) (closed as +superseded). Umbrella vision issues #4, #6, #12, #13, and #14 restate the same +direction and are closed in favor of #25. + +## Goal + +`ourocode` should let users run installed Ouroboros UserLevel plugins from the +terminal UI with direct `ooo ...` prompts. A user should not need to +leave `ourocode`, run a shell command, find generated handoff artifacts, and +then manually re-enter `ooo run`. + +The motivating example is `Q00/ouroboros-plugins` `superpowers`: + +```text +ooo superpowers test-driven-development --goal "Add retry behavior" +``` + +or, when the user explicitly opts into continuation: + +```text +ooo superpowers test-driven-development --goal "..." then run the generated handoff +``` + +Free-form natural-language dispatch ("Use Superpowers TDD for this change") is +deferred until the exact-match path is stable. Slash-shaped `/superpowers ...` +input continues to flow through the existing +`Ourocode.Command.CapabilityPreflight` slash-command surface; the work in this +document adds a sibling path for `ooo`-prefixed input that the slash surface +does not cover. + +## Current boundary (as of `release/bootstrap` v0.1.11) + +`ourocode` already has most of the pieces it needs and is missing the +UserLevel-specific bridge. + +In place upstream: + +- `Ourocode.Runtime.Router` classifies `ooo`/`ouroboros` shortcuts into + `interview`, `seed`, `run`, `evolve`, `ralph`, and `workflow` adapter routes. +- `Ourocode.Runtime.Dispatcher` enforces shell-injection guards + (`@forbidden_external_commands`, `@shell_commands`) for any external command + runner the adapters call. +- `Ourocode.Runtime.OuroborosWorkflowInvocation` already invokes the MCP + `ouroboros_generate_seed` and `ouroboros_start_execute_seed` tools and is the + natural continuation target for plugin-generated seeds. +- `Ourocode.Command.Registry` models a `:plugin` source with normalized + command entries, args, aliases, run_spec, and trust metadata. +- `Ourocode.Command.CapabilityPreflight` resolves slash-shaped (`/foo`) + command input read-only and projects trust state via + `CapabilityPreflight.Trust` and `CapabilityPreflight.Projection`. +- `Ourocode.Plugin.*` loads declarative plugin mappings (adapter / action / + renderer) and the CLI bootstrap now loads `.ourocode/config.json` plugin + entries during startup. + +Still missing for UserLevel plugin dispatch: + +- A discovery surface that asks Ouroboros (CLI today, MCP tomorrow) which + UserLevel plugins are installed, which commands they expose, and which + artifacts they may produce. +- An execution route for `ooo ...` that is distinct from + the existing Ouroboros workflow shortcuts and from the slash-command surface. +- A dispatch adapter that turns a preflight result into an argv invocation + through `Dispatcher.guarded_external_command_runner` and streams output into + a child pane. +- Trust-blocked rendering that surfaces the exact `ouroboros plugin trust ...` + remediation without granting trust on the user's behalf. +- Detection of plugin-generated artifacts (such as `seed.md`) and an + intent-gated continuation into `ooo run seed_path=...`. + +## Implementation plan + +The work is split into five reviewable PRs that build on each other. Each PR +keeps the SSOT primitives in #25 intact and adds exactly one new primitive. + +### PR 1 — vision normalization (this PR) + +Pure docs and issue hygiene. No code changes. + +- Re-anchor this document on #25 and the in-tree `docs/product-vision.md`. +- Close umbrella restatement issues (#4, #6, #12, #13, #14) as + superseded by #25. +- Inject the PR breakdown into the SSOT issue task list so progress is visible. + +Closes none of the implementation-shaped issues by itself; unblocks all later +PRs by giving them a single shared framing. + +### PR 2 — UserLevel plugin capability layer + +Adds the `Ourocode.Plugin.UserLevel.*` namespace. + +- `Capability` and `CommandCapability` structs that record plugin identity + (id, source, version, install scope, trust scope, manifest digest) and + per-command metadata (name, aliases, args, risk class, expected artifacts, + continuation hint). +- `Discovery` behaviour with an `OuroborosCLI` adapter that calls + `ouroboros plugin list --json` through the existing guarded external command + runner. Failure surfaces as a `:degraded` registry status rather than a + startup crash. +- `Registry` Agent that caches the latest capability list with a 60 s TTL, + invalidates on `Plugin.ConfigWatcher` signals, and exposes a manual + `/plugins refresh` slash command. +- A wiring step that publishes UserLevel capabilities into the existing + `Command.Registry` `:plugin` source so the upstream + `CapabilityPreflight` and `/preflight` surfaces immediately see them. + +Closes #5, #8, #9, #18, #27, #29. + +### PR 3 — preflight resolver and router glue + +Adds the `ooo`-prefixed resolution path that the existing slash-shaped +preflight does not cover. + +- `PreflightResult` struct mirroring the upstream slash-shaped projection but + scoped to `ooo`-shaped input. +- `Resolver` pure function that turns a `TaskRequest` into a `PreflightResult` + with kind `:unique_match`, `:ambiguous`, or `:unknown`, including trust + state and remediation text. +- A `:user_level_plugin` execution route in `Router` and `Dispatcher` + triggered only when the second token exactly matches a known plugin name. +- Tests cover unique match, ambiguous match, unknown plugin, missing trust, + and shell-injection input. + +Closes #16, #23. + +### PR 4 — dispatch adapter, trust UX, Superpowers vertical slice + +Connects the resolver to actual execution. + +- `Ourocode.Runtime.UserLevelPluginInvocation` adapter that implements + `Ourocode.Runtime.Adapter` and translates a preflight result into an argv + invocation through `Dispatcher.guarded_external_command_runner`. +- `PreflightPanel` TUI module that renders the preflight (plugin, command, + args, risk class, trust state, expected artifacts) and surfaces the exact + `ouroboros plugin trust ...` remediation when trust is missing. No silent + grants. +- A `superpowers` discovery fixture so `ooo superpowers list` can run end to + end in tests. + +Closes #15, #17 (minimal trust-blocked structured error path), #20, #21. + +### PR 5 — artifact capture, continuation policy, decision journal + +Closes the loop from dispatch to follow-up workflow. + +- `ArtifactWatcher` that scans `Capability.expected_artifacts` after the + plugin run completes, producing `%Artifact{}` records attached to the + current session. +- `Continuation` policy module: read-only commands stop, handoff-producing + commands suggest `ooo run seed_path=...`, and the suggestion runs + automatically only when the user's prompt explicitly opted in (for example, + "then run the generated handoff"). +- `DecisionJournal` appends one structured event per phase + (`:preflight`, `:dispatch`, `:artifact`, `:continuation`) into the existing + `lib/ourocode/journal/` writer. + +Closes #7, #10, #11, #26, #28. + +## Deferred (no PR yet) + +The following vision issues are valuable but premature given current usage +signal. Each is left open with a `defer:awaiting-signal` note that records the +trigger condition under which it should ship. + +- #19 — recipes: ships after a recurring user pattern emerges (same plugin + command invoked five or more times by the same user with the same shape). +- #22 — context packets: ships after a plugin declares an `accepts_context` + capability so leakage versus underuse is observable. +- #24 — durable sessions and full failure recovery: ships when long-running + (greater than 10 minute) plugin invocations or plugin-side pause/resume + hooks appear. + +## Continuation policy (PR 5 specifics) + +Continuation behavior is intentionally conservative. + +- A command whose declared risk class is `:read_only` finishes after rendering + output. No follow-up is offered. +- A command whose declared risk class is `:handoff_producing` triggers + artifact watching. If a `seed.md` or other declared artifact is found, the + continuation card shows the exact `ooo run seed_path=...` command. +- The continuation runs automatically only when the original prompt contains + an explicit opt-in token such as "then run the generated handoff" or the + Korean "이어서 실행". Otherwise it waits for user confirmation. +- A command whose declared risk class is `:destructive` requires explicit + user approval inside the preflight panel before dispatch and never + auto-continues. + +## Acceptance tests carried into PR 4 and PR 5 + +Carried forward from the original #2 acceptance criteria, rescoped to fit the +plan above. + +1. From inside `ourocode`, `ooo superpowers list` invokes the installed + `superpowers` plugin (or a discovery fixture in tests) and renders output + in a child pane. +2. From inside `ourocode`, `ooo superpowers test-driven-development --goal "..."` + surfaces the preflight, dispatches the plugin, and detects the + declared `seed.md` artifact through `Capability.expected_artifacts`. +3. When a plugin command generates a seed artifact, `ourocode` shows the + suggested `ooo run seed_path=...` continuation and only auto-runs it when + the prompt opted in. +4. Missing or untrusted plugin states render the exact + `ouroboros plugin trust ... --scope ...` remediation string and do not + grant trust silently. +5. Shell injection attempts in plugin arguments are passed through as argv + tokens; `Dispatcher.guarded_external_command_runner` guards remain green. + +## Non-goals + +- Installing or trusting plugins automatically. +- Creating a plugin marketplace or catalog UI. +- Replacing Ouroboros plugin firewall semantics inside `ourocode`. +- Executing destructive plugin actions in the initial bridge. +- Free-form natural-language dispatch beyond exact plugin-name plus + command-name token matching. +- Inferring plugin-internal storage paths; `ourocode` only trusts the + `expected_artifacts` glob list a capability declares. From 598f3adf867fcc20dee1e0093dc3f928265af524 Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Mon, 25 May 2026 17:05:03 +0900 Subject: [PATCH 2/7] feat(plugin): add UserLevel plugin capability layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the Ourocode.Plugin.UserLevel namespace that lets the runtime discover installed Ouroboros UserLevel plugins and treat their commands as first-class registry entries without reimplementing trust or storage semantics. What is added: - Ourocode.Plugin.UserLevel.Capability + .Capability.Command Normalized identity and command surface (plugin_id, source, version, install scope, trust scopes, manifest digest, declared commands, expected artifacts, continuation hints). Identity stability via (plugin_id, version, manifest_digest) tuple so re-discovery without manifest changes returns the same struct. - Ourocode.Plugin.UserLevel.Discovery Behaviour with a Discovery.run/2 helper that normalizes raw descriptors into Capability structs and surfaces per-descriptor validation errors separately so one bad command never loses a whole plugin. - Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI First discovery adapter; invokes `ouroboros plugin list --json` via a pluggable command runner. Tests inject a stub runner to avoid spawning real processes. Failure modes (exit != 0, runner unavailable, malformed JSON, unexpected shape) all surface as structured errors. - Ourocode.Plugin.UserLevel.Registry Small Agent that caches the latest discovery snapshot with a 60 s TTL, explicit refresh, and identity-preserving merge. Discovery failure degrades the snapshot but preserves last good capabilities, so missing/broken ouroboros CLI never blocks boot. - Ourocode.Plugin.UserLevel.RegistryEntry Projects Capability into the existing Command.Registry plugin-source entry shape (mirrors PluginSurfaceEntry's metadata so the existing CapabilityPreflight.Trust and Projection modules apply unchanged). What is NOT changed in this PR: - No supervision wiring — the registry is standalone and ships dead code until PR 4 wires it into application_services.ex alongside the dispatch adapter that needs it. This keeps PR 2 boot-safe. - No new slash command — `/plugins refresh` ships with PR 4. - No router/dispatcher changes — those land in PR 3. Tests: 5 ExUnit files (1255 LOC total with lib code) cover capability shape, identity, command lookup, discovery normalization, OuroborosCLI parsing of the superpowers fixture, registry TTL + degraded handling, identity-stable merge, and registry projection into the existing plugin-source entry shape. Closes #5 Closes #8 Closes #9 Closes #18 Closes #27 Closes #29 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/ourocode/plugin/user_level/capability.ex | 124 ++++++++++++ .../plugin/user_level/capability/command.ex | 129 ++++++++++++ lib/ourocode/plugin/user_level/discovery.ex | 50 +++++ .../user_level/discovery/ouroboros_cli.ex | 170 ++++++++++++++++ lib/ourocode/plugin/user_level/registry.ex | 189 ++++++++++++++++++ .../plugin/user_level/registry_entry.ex | 128 ++++++++++++ .../user_level_plugins/superpowers.json | 62 ++++++ .../plugin/user_level/capability_test.exs | 138 +++++++++++++ .../discovery/ouroboros_cli_test.exs | 71 +++++++ .../plugin/user_level/discovery_test.exs | 47 +++++ .../plugin/user_level/registry_entry_test.exs | 98 +++++++++ .../plugin/user_level/registry_test.exs | 117 +++++++++++ 12 files changed, 1323 insertions(+) create mode 100644 lib/ourocode/plugin/user_level/capability.ex create mode 100644 lib/ourocode/plugin/user_level/capability/command.ex create mode 100644 lib/ourocode/plugin/user_level/discovery.ex create mode 100644 lib/ourocode/plugin/user_level/discovery/ouroboros_cli.ex create mode 100644 lib/ourocode/plugin/user_level/registry.ex create mode 100644 lib/ourocode/plugin/user_level/registry_entry.ex create mode 100644 test/fixtures/user_level_plugins/superpowers.json create mode 100644 test/ourocode/plugin/user_level/capability_test.exs create mode 100644 test/ourocode/plugin/user_level/discovery/ouroboros_cli_test.exs create mode 100644 test/ourocode/plugin/user_level/discovery_test.exs create mode 100644 test/ourocode/plugin/user_level/registry_entry_test.exs create mode 100644 test/ourocode/plugin/user_level/registry_test.exs diff --git a/lib/ourocode/plugin/user_level/capability.ex b/lib/ourocode/plugin/user_level/capability.ex new file mode 100644 index 0000000..f157e82 --- /dev/null +++ b/lib/ourocode/plugin/user_level/capability.ex @@ -0,0 +1,124 @@ +defmodule Ourocode.Plugin.UserLevel.Capability do + @moduledoc """ + Normalized identity and command surface for one installed Ouroboros + UserLevel plugin. + + `ourocode` only consumes this struct. Ouroboros remains the source of truth + for installation, trust, and execution; this module describes what was + discovered so the runtime can route, preflight, and render UserLevel plugin + commands without guessing. + """ + + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @enforce_keys [:plugin_id, :source] + defstruct plugin_id: nil, + plugin_name: nil, + source: nil, + version: nil, + install_scope: :unknown, + trust_scope: [], + manifest_digest: nil, + commands: [], + discovered_at: nil, + resolution_origin: %{} + + @type install_scope :: :user | :workspace | :unknown + @type trust_scope :: String.t() + @type source :: :ouroboros_cli | :ouroboros_mcp | :fixture + + @type t :: %__MODULE__{ + plugin_id: String.t(), + plugin_name: String.t() | nil, + source: source(), + version: String.t() | nil, + install_scope: install_scope(), + trust_scope: [trust_scope()], + manifest_digest: String.t() | nil, + commands: [CommandCapability.t()], + discovered_at: DateTime.t() | nil, + resolution_origin: map() + } + + @valid_sources [:ouroboros_cli, :ouroboros_mcp, :fixture] + @valid_scopes [:user, :workspace, :unknown] + + @doc """ + Builds a `Capability` from a normalized descriptor produced by a discovery + adapter. + + Required fields: + * `plugin_id` (non-empty string) + * `source` (atom in `#{inspect(@valid_sources)}`) + + Invalid command descriptors are dropped silently so a single bad command + does not lose the whole plugin. Top-level shape violations return + `{:error, :invalid_capability_attrs}`. + """ + @spec new(map()) :: {:ok, t()} | {:error, :invalid_capability_attrs} + def new(%{plugin_id: id, source: source} = attrs) + when is_binary(id) and id != "" and source in @valid_sources do + commands = + attrs + |> Map.get(:commands, []) + |> List.wrap() + |> Enum.flat_map(fn descriptor -> + case CommandCapability.new(descriptor) do + {:ok, command} -> [command] + {:error, _reason} -> [] + end + end) + + install_scope = Map.get(attrs, :install_scope, :unknown) + install_scope = if install_scope in @valid_scopes, do: install_scope, else: :unknown + + {:ok, + %__MODULE__{ + plugin_id: id, + plugin_name: Map.get(attrs, :plugin_name) || id, + source: source, + version: Map.get(attrs, :version), + install_scope: install_scope, + trust_scope: normalize_scopes(Map.get(attrs, :trust_scope, [])), + manifest_digest: Map.get(attrs, :manifest_digest), + commands: commands, + discovered_at: Map.get(attrs, :discovered_at) || DateTime.utc_now(), + resolution_origin: Map.get(attrs, :resolution_origin, %{}) + }} + end + + def new(_attrs), do: {:error, :invalid_capability_attrs} + + @doc """ + Canonical identity tuple used for cache equality and identity stability. + + Two capabilities with the same `{plugin_id, version, manifest_digest}` are + considered the same artifact even across re-discoveries. + """ + @spec identity(t()) :: {String.t(), String.t() | nil, String.t() | nil} + def identity(%__MODULE__{plugin_id: id, version: version, manifest_digest: digest}) do + {id, version, digest} + end + + @doc """ + Finds a command on the capability by canonical name or alias. + + Returns `nil` when neither matches; callers should treat that as + `:unknown` rather than guessing. + """ + @spec find_command(t(), String.t()) :: CommandCapability.t() | nil + def find_command(%__MODULE__{commands: commands}, token) when is_binary(token) do + Enum.find(commands, fn cmd -> + cmd.name == token or token in cmd.aliases + end) + end + + defp normalize_scopes(scopes) do + scopes + |> List.wrap() + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end +end diff --git a/lib/ourocode/plugin/user_level/capability/command.ex b/lib/ourocode/plugin/user_level/capability/command.ex new file mode 100644 index 0000000..28aef96 --- /dev/null +++ b/lib/ourocode/plugin/user_level/capability/command.ex @@ -0,0 +1,129 @@ +defmodule Ourocode.Plugin.UserLevel.Capability.Command do + @moduledoc """ + Per-command capability metadata declared by an installed UserLevel plugin. + + This struct is read-only and never owns trust state, execution, or storage + paths. Trust and execution live in Ouroboros; storage paths are derived from + `expected_artifacts` glob declarations the plugin itself publishes. + """ + + @enforce_keys [:name] + defstruct name: nil, + aliases: [], + summary: nil, + args: [], + risk_class: :unknown, + expected_artifacts: [], + continuation_hint: :none + + @type risk_class :: :read_only | :handoff_producing | :destructive | :unknown + @type continuation_hint :: :none | :suggest_run | :auto_run_when_requested + @type arg :: %{ + required(:name) => String.t(), + required(:required?) => boolean(), + required(:repeatable?) => boolean(), + required(:description) => String.t() + } + + @type t :: %__MODULE__{ + name: String.t(), + aliases: [String.t()], + summary: String.t() | nil, + args: [arg()], + risk_class: risk_class(), + expected_artifacts: [String.t()], + continuation_hint: continuation_hint() + } + + @doc """ + Builds a `Command` capability from a normalized descriptor. + + Returns `{:error, :invalid_command_attrs}` when `name` is missing or blank + so the registry can drop the descriptor without aborting discovery of the + surrounding plugin. + """ + @spec new(map()) :: {:ok, t()} | {:error, :invalid_command_attrs} + def new(%{name: name} = attrs) when is_binary(name) and name != "" do + {:ok, + %__MODULE__{ + name: name, + aliases: normalize_aliases(Map.get(attrs, :aliases, [])), + summary: normalize_summary(Map.get(attrs, :summary)), + args: normalize_args(Map.get(attrs, :args, [])), + risk_class: normalize_risk(Map.get(attrs, :risk_class, :unknown)), + expected_artifacts: normalize_artifacts(Map.get(attrs, :expected_artifacts, [])), + continuation_hint: normalize_continuation(Map.get(attrs, :continuation_hint, :none)) + }} + end + + def new(_attrs), do: {:error, :invalid_command_attrs} + + defp normalize_aliases(aliases) do + aliases + |> List.wrap() + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp normalize_summary(nil), do: nil + defp normalize_summary(value) when is_binary(value), do: value + defp normalize_summary(_other), do: nil + + defp normalize_args(args) do + args + |> List.wrap() + |> Enum.map(&normalize_arg/1) + |> Enum.reject(&(&1.name == "")) + end + + defp normalize_arg(%{name: name} = attrs) when is_binary(name) do + %{ + name: name, + required?: truthy?(Map.get(attrs, :required?, Map.get(attrs, :required, false))), + repeatable?: truthy?(Map.get(attrs, :repeatable?, Map.get(attrs, :repeatable, false))), + description: to_description(Map.get(attrs, :description, "")) + } + end + + defp normalize_arg(name) when is_binary(name) do + %{name: name, required?: false, repeatable?: false, description: ""} + end + + defp normalize_arg(_other), + do: %{name: "", required?: false, repeatable?: false, description: ""} + + defp normalize_artifacts(artifacts) do + artifacts + |> List.wrap() + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp normalize_risk(value) + when value in [:read_only, :handoff_producing, :destructive, :unknown], + do: value + + defp normalize_risk("read_only"), do: :read_only + defp normalize_risk("handoff_producing"), do: :handoff_producing + defp normalize_risk("destructive"), do: :destructive + defp normalize_risk(_other), do: :unknown + + defp normalize_continuation(value) + when value in [:none, :suggest_run, :auto_run_when_requested], + do: value + + defp normalize_continuation("none"), do: :none + defp normalize_continuation("suggest_run"), do: :suggest_run + defp normalize_continuation("auto_run_when_requested"), do: :auto_run_when_requested + defp normalize_continuation(_other), do: :none + + defp to_description(value) when is_binary(value), do: value + defp to_description(_other), do: "" + + defp truthy?(true), do: true + defp truthy?("true"), do: true + defp truthy?(_other), do: false +end diff --git a/lib/ourocode/plugin/user_level/discovery.ex b/lib/ourocode/plugin/user_level/discovery.ex new file mode 100644 index 0000000..79b2de8 --- /dev/null +++ b/lib/ourocode/plugin/user_level/discovery.ex @@ -0,0 +1,50 @@ +defmodule Ourocode.Plugin.UserLevel.Discovery do + @moduledoc """ + Behaviour for discovering installed Ouroboros UserLevel plugins. + + Discovery adapters are read-only: they ask Ouroboros which plugins are + installed and return one descriptor per plugin. They must never install, + trust, escalate, or execute plugin code. Caching, freshness, and identity + stability live in `Ourocode.Plugin.UserLevel.Registry`, not in the adapter. + + The behaviour is transport-neutral: a CLI adapter and an MCP adapter can + both satisfy it, and the registry treats them interchangeably. + """ + + alias Ourocode.Plugin.UserLevel.Capability + + @type raw_descriptor :: map() + @type discovery_options :: keyword() | map() + @type discovery_result :: + {:ok, [raw_descriptor()]} + | {:error, atom() | {atom(), term()}} + + @callback discover(discovery_options()) :: discovery_result() + + @doc """ + Runs an adapter and normalizes raw descriptors into `Capability` structs. + + Per-descriptor validation failures are reported separately so the registry + can keep the good capabilities while logging the bad ones. Adapter-level + failures bubble up unchanged. + """ + @spec run(module(), discovery_options()) :: + {:ok, [Capability.t()], [{:invalid_descriptor, term()}]} + | {:error, term()} + def run(adapter, opts \\ []) when is_atom(adapter) do + with {:ok, descriptors} <- adapter.discover(opts) do + {capabilities, errors} = + Enum.reduce(descriptors, {[], []}, fn descriptor, {ok_acc, err_acc} -> + case Capability.new(descriptor) do + {:ok, capability} -> + {[capability | ok_acc], err_acc} + + {:error, reason} -> + {ok_acc, [{:invalid_descriptor, {reason, descriptor}} | err_acc]} + end + end) + + {:ok, Enum.reverse(capabilities), Enum.reverse(errors)} + end + end +end diff --git a/lib/ourocode/plugin/user_level/discovery/ouroboros_cli.ex b/lib/ourocode/plugin/user_level/discovery/ouroboros_cli.ex new file mode 100644 index 0000000..c79d61f --- /dev/null +++ b/lib/ourocode/plugin/user_level/discovery/ouroboros_cli.ex @@ -0,0 +1,170 @@ +defmodule Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI do + @moduledoc """ + Discovers installed UserLevel plugins by invoking + `ouroboros plugin list --json`. + + This adapter is the first-class discovery surface until a dedicated MCP + plugin-list tool exists. It is read-only: it never installs, trusts, or + executes plugin code. + + Tests inject a stub runner via the `:command_runner` option so that no + external process is spawned. The runner contract is + `runner.(command, args, opts) :: {:ok, %{status: integer, stdout: binary, + stderr: binary}} | {:error, term()}`. + """ + + @behaviour Ourocode.Plugin.UserLevel.Discovery + + alias Ourocode.Json + + @default_command "ouroboros" + @default_args ["plugin", "list", "--json"] + @default_timeout_ms 5_000 + + @impl true + @spec discover(keyword() | map()) :: {:ok, [map()]} | {:error, term()} + def discover(opts \\ []) do + opts = if is_map(opts), do: opts, else: Map.new(opts) + command = Map.get(opts, :command, @default_command) + args = Map.get(opts, :args, @default_args) + runner = Map.get(opts, :command_runner, &default_runner/3) + timeout_ms = Map.get(opts, :timeout_ms, @default_timeout_ms) + + case runner.(command, args, %{timeout_ms: timeout_ms}) do + {:ok, %{status: 0, stdout: stdout}} -> + parse(stdout) + + {:ok, %{status: status} = result} when status != 0 -> + {:error, + {:ouroboros_cli_failed, + %{exit_status: status, stderr: Map.get(result, :stderr, "")}}} + + {:ok, other} -> + {:error, {:ouroboros_cli_unexpected_result, other}} + + {:error, reason} -> + {:error, {:ouroboros_cli_unavailable, reason}} + end + end + + @doc """ + Parses an `ouroboros plugin list --json` payload into the descriptor shape + expected by `Ourocode.Plugin.UserLevel.Capability.new/1`. + + Public so tests can validate the parser without going through the runner + indirection. + """ + @spec parse(binary()) :: {:ok, [map()]} | {:error, term()} + def parse(stdout) when is_binary(stdout) do + case Json.decode(stdout) do + {:ok, %{"plugins" => plugins}} when is_list(plugins) -> + {:ok, Enum.map(plugins, &normalize_plugin/1)} + + {:ok, plugins} when is_list(plugins) -> + {:ok, Enum.map(plugins, &normalize_plugin/1)} + + {:ok, _other} -> + {:error, :ouroboros_cli_unexpected_shape} + + {:error, reason} -> + {:error, {:ouroboros_cli_invalid_json, reason}} + end + end + + defp normalize_plugin(plugin) when is_map(plugin) do + %{ + plugin_id: read(plugin, ["id", "plugin_id", "name"]), + plugin_name: read(plugin, ["name", "display_name", "id"]), + source: :ouroboros_cli, + version: read(plugin, ["version"]), + install_scope: normalize_scope(read(plugin, ["install_scope", "scope"])), + trust_scope: + plugin + |> read(["trust_scope", "trust_scopes"], []) + |> List.wrap() + |> Enum.filter(&is_binary/1), + manifest_digest: read(plugin, ["manifest_digest", "digest"]), + commands: + plugin + |> read(["commands"], []) + |> List.wrap() + |> Enum.map(&normalize_command/1), + resolution_origin: %{ + adapter: __MODULE__, + call: %{command: @default_command, args: @default_args} + } + } + end + + defp normalize_plugin(_other), do: %{plugin_id: nil, source: :ouroboros_cli} + + defp normalize_command(cmd) when is_map(cmd) do + %{ + name: read(cmd, ["name", "command"]) || "", + aliases: cmd |> read(["aliases"], []) |> List.wrap() |> Enum.filter(&is_binary/1), + summary: read(cmd, ["summary", "description"]), + args: + cmd + |> read(["args", "arguments"], []) + |> List.wrap() + |> Enum.map(&normalize_arg/1), + risk_class: read(cmd, ["risk_class", "risk"]), + expected_artifacts: + cmd + |> read(["expected_artifacts", "artifacts"], []) + |> List.wrap() + |> Enum.filter(&is_binary/1), + continuation_hint: read(cmd, ["continuation_hint", "continuation"]) + } + end + + defp normalize_command(_other), do: %{name: ""} + + defp normalize_arg(arg) when is_map(arg) do + %{ + name: read(arg, ["name", "arg"]) || "", + required?: truthy?(read(arg, ["required", "required?"])), + repeatable?: truthy?(read(arg, ["repeatable", "repeatable?"])), + description: to_string_safe(read(arg, ["description", "summary"])) + } + end + + defp normalize_arg(arg) when is_binary(arg), + do: %{name: arg, required?: false, repeatable?: false, description: ""} + + defp normalize_arg(_other), + do: %{name: "", required?: false, repeatable?: false, description: ""} + + defp normalize_scope("user"), do: :user + defp normalize_scope("workspace"), do: :workspace + defp normalize_scope("project"), do: :workspace + defp normalize_scope(_other), do: :unknown + + defp read(map, keys, default \\ nil) when is_map(map) and is_list(keys) do + Enum.find_value(keys, default, fn key -> + case Map.get(map, key) do + nil -> nil + "" -> nil + value -> value + end + end) + end + + defp to_string_safe(nil), do: "" + defp to_string_safe(value) when is_binary(value), do: value + defp to_string_safe(value), do: to_string(value) + + defp truthy?(true), do: true + defp truthy?("true"), do: true + defp truthy?(_other), do: false + + defp default_runner(command, args, _opts) when is_binary(command) and is_list(args) do + case System.cmd(command, args, stderr_to_stdout: false) do + {output, 0} -> {:ok, %{status: 0, stdout: output, stderr: ""}} + {output, status} -> {:ok, %{status: status, stdout: "", stderr: output}} + end + rescue + error in [ErlangError, File.Error, System.EnvError] -> + {:error, {:command_runner_raised, Exception.message(error)}} + end +end diff --git a/lib/ourocode/plugin/user_level/registry.ex b/lib/ourocode/plugin/user_level/registry.ex new file mode 100644 index 0000000..af4ac6a --- /dev/null +++ b/lib/ourocode/plugin/user_level/registry.ex @@ -0,0 +1,189 @@ +defmodule Ourocode.Plugin.UserLevel.Registry do + @moduledoc """ + In-memory cache of installed Ouroboros UserLevel plugin capabilities. + + The registry is a small Agent that keeps the most recent discovery + snapshot. Lookups are O(N) over a tiny N; the registry exists for + freshness and identity stability, not for high-throughput access. + + Freshness: + * `list/2` returns the cached snapshot, refreshing when it is older than + `:max_age_ms` (default 60 s). + * `refresh/2` is explicit (used by a `/plugins refresh` slash command + and by the plugin config watcher signal handler). + * Discovery failures degrade the snapshot to `:degraded` while keeping + the last good capability list. Boot is never blocked. + + Identity stability: + * Capabilities are deduplicated by + `Ourocode.Plugin.UserLevel.Capability.identity/1`. A re-discovery + without manifest changes returns the same struct instance, so + downstream caches (preflight, panel, journal) do not churn. + """ + + use Agent + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Discovery + alias Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI + + @default_ttl_ms 60_000 + @default_adapter OuroborosCLI + + @type status :: :ready | :degraded | :empty + @type snapshot :: %{ + required(:status) => status(), + required(:capabilities) => [Capability.t()], + required(:errors) => [term()], + required(:refreshed_at) => DateTime.t() | nil, + required(:adapter) => module() + } + + @doc """ + Starts the registry agent. + + Options: + * `:name` — registered process name (defaults to `__MODULE__`). + * `:adapter` — discovery adapter module (defaults to `OuroborosCLI`). + * `:adapter_options` — passed verbatim to `adapter.discover/1`. + * `:eager?` — when `true`, runs an initial discovery synchronously. + Defaults to `false` so boot stays fast and offline-safe. + """ + @spec start_link(keyword()) :: Agent.on_start() + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + adapter = Keyword.get(opts, :adapter, @default_adapter) + adapter_options = Keyword.get(opts, :adapter_options, []) + eager? = Keyword.get(opts, :eager?, false) + + initial = %{ + status: :empty, + capabilities: [], + errors: [], + refreshed_at: nil, + adapter: adapter, + adapter_options: adapter_options + } + + case Agent.start_link(fn -> initial end, name: name) do + {:ok, pid} -> + if eager?, do: _ = refresh(name) + {:ok, pid} + + other -> + other + end + end + + @doc """ + Returns the current snapshot, refreshing when older than `:max_age_ms`. + + Options: + * `:max_age_ms` — TTL for the cached snapshot (defaults to + `#{@default_ttl_ms}` ms). Pass `nil` to never auto-refresh — the + cached snapshot is returned unchanged even when it is still empty, + so callers can inspect the initial state without triggering + discovery. To force discovery on first read, pass `max_age_ms: 0` + or call `refresh/2` explicitly. + """ + @spec list(GenServer.server(), keyword()) :: snapshot() + def list(server \\ __MODULE__, opts \\ []) do + max_age_ms = Keyword.get(opts, :max_age_ms, @default_ttl_ms) + snapshot = Agent.get(server, & &1) + + if stale?(snapshot, max_age_ms) do + refresh(server) + else + project(snapshot) + end + end + + @doc """ + Forces re-discovery through the configured adapter and updates the cache. + + Discovery failures preserve the previous capability list and surface as a + `:degraded` snapshot with the error attached. Successful runs reset the + error list and update `refreshed_at`. + """ + @spec refresh(GenServer.server(), keyword()) :: snapshot() + def refresh(server \\ __MODULE__, opts \\ []) do + Agent.get_and_update(server, fn current -> + adapter = Keyword.get(opts, :adapter, current.adapter) + adapter_options = Keyword.get(opts, :adapter_options, current.adapter_options) + + next = run_discovery(current, adapter, adapter_options) + + new_state = + current + |> Map.put(:adapter, adapter) + |> Map.put(:adapter_options, adapter_options) + |> Map.merge(next) + + {project(new_state), new_state} + end) + end + + @doc """ + Looks up a capability by plugin id from the cached snapshot. + + Does not trigger discovery. Callers that need freshness should call + `list/2` first. + """ + @spec fetch(GenServer.server(), String.t()) :: {:ok, Capability.t()} | :error + def fetch(server \\ __MODULE__, plugin_id) when is_binary(plugin_id) do + snapshot = Agent.get(server, & &1) + + case Enum.find(snapshot.capabilities, &(&1.plugin_id == plugin_id)) do + nil -> :error + capability -> {:ok, capability} + end + end + + # `max_age_ms: nil` takes precedence over an empty cache so callers can + # inspect the initial state without triggering discovery. + defp stale?(_snapshot, nil), do: false + defp stale?(%{refreshed_at: nil}, _max_age_ms), do: true + + defp stale?(%{refreshed_at: refreshed_at}, max_age_ms) do + DateTime.diff(DateTime.utc_now(), refreshed_at, :millisecond) > max_age_ms + end + + defp run_discovery(current, adapter, adapter_options) do + case Discovery.run(adapter, adapter_options) do + {:ok, capabilities, descriptor_errors} -> + merged = preserve_identity(current.capabilities, capabilities) + + %{ + status: status_for(merged, descriptor_errors), + capabilities: merged, + errors: descriptor_errors, + refreshed_at: DateTime.utc_now(), + adapter: adapter + } + + {:error, reason} -> + %{ + status: :degraded, + capabilities: current.capabilities, + errors: [{:discovery_failed, reason} | current.errors], + refreshed_at: DateTime.utc_now(), + adapter: adapter + } + end + end + + defp status_for([], []), do: :empty + defp status_for(_capabilities, _errors), do: :ready + + defp preserve_identity(previous, fresh) do + index = Map.new(previous, fn cap -> {Capability.identity(cap), cap} end) + + Enum.map(fresh, fn cap -> + Map.get(index, Capability.identity(cap), cap) + end) + end + + defp project(snapshot) do + Map.take(snapshot, [:status, :capabilities, :errors, :refreshed_at, :adapter]) + end +end diff --git a/lib/ourocode/plugin/user_level/registry_entry.ex b/lib/ourocode/plugin/user_level/registry_entry.ex new file mode 100644 index 0000000..77f7f0d --- /dev/null +++ b/lib/ourocode/plugin/user_level/registry_entry.ex @@ -0,0 +1,128 @@ +defmodule Ourocode.Plugin.UserLevel.RegistryEntry do + @moduledoc """ + Projects `Ourocode.Plugin.UserLevel.Capability` into the normalized + command_entry shape consumed by `Ourocode.Command.Registry`. + + This bridge lets the existing slash command surface and + `Ourocode.Command.CapabilityPreflight` see UserLevel plugins as first-class + registry entries without duplicating projection logic. The metadata shape + intentionally mirrors `Ourocode.Command.Registry.PluginSurfaceEntry` so + `CapabilityPreflight.Trust` and `CapabilityPreflight.Projection` work + unchanged. + + Trust defaults are conservative: the registry assumes `requires_explicit_approval` + unless the discovered capability declares trust scopes. Granting trust + remains an Ouroboros responsibility; `ourocode` only surfaces what was + reported. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @doc """ + Returns a list of registry-shaped maps suitable for + `Ourocode.Command.Registry.merge_normalized_entries/2`. + + One entry per command capability is produced. + """ + @spec entries([Capability.t()] | Capability.t()) :: [map()] + def entries(capabilities) when is_list(capabilities) do + Enum.flat_map(capabilities, &entries/1) + end + + def entries(%Capability{} = capability) do + Enum.map(capability.commands, &entry(capability, &1)) + end + + defp entry(%Capability{} = capability, %CommandCapability{} = command) do + slash = "/" <> capability.plugin_id <> " " <> command.name + + aliases = + Enum.map(command.aliases, fn alias_name -> + "/" <> capability.plugin_id <> " " <> alias_name + end) + + args = + Enum.map(command.args, fn arg -> + %{ + name: Map.get(arg, :name, ""), + required?: Map.get(arg, :required?, false), + description: Map.get(arg, :description, "") + } + end) + + %{ + id: "user_level_plugin:#{capability.plugin_id}:#{command.name}", + name: "#{capability.plugin_id} #{command.name}", + slash: slash, + source: :plugin, + source_id: capability.plugin_id, + source_attribution: source_attribution(capability), + type: :slash_command, + category: :plugins, + summary: command.summary || "", + aliases: aliases, + args: args, + availability: :available, + runnable?: true, + run_spec: %{ + kind: :user_level_plugin_command, + plugin_id: capability.plugin_id, + command: command.name, + risk_class: command.risk_class, + expected_artifacts: command.expected_artifacts, + continuation_hint: command.continuation_hint + }, + metadata: %{ + plugin_id: capability.plugin_id, + plugin_source: capability.source, + plugin_surface: :user_level, + command_namespace: capability.plugin_id, + namespace_owner: :ouroboros, + trust_policy: trust_policy(capability), + trust_evaluation: trust_evaluation(capability), + trust_policy_state: nil, + expected_outputs: command.expected_artifacts, + risk_class: command.risk_class, + capability_version: capability.version, + manifest_digest: capability.manifest_digest + } + } + end + + defp source_attribution(%Capability{} = capability) do + %{ + source: :plugin, + source_id: capability.plugin_id, + plugin_id: capability.plugin_id, + plugin_source: capability.source, + plugin_surface: :user_level, + command_namespace: capability.plugin_id, + namespace_owner: :ouroboros, + capability_version: capability.version, + manifest_digest: capability.manifest_digest + } + end + + defp trust_policy(%Capability{trust_scope: scopes}) when scopes != [] do + %{ + "tier" => "user_level", + "requires_explicit_approval" => false, + "trust_scopes" => scopes + } + end + + defp trust_policy(_capability) do + %{ + "tier" => "user_level", + "requires_explicit_approval" => true + } + end + + defp trust_evaluation(%Capability{trust_scope: scopes}) do + %{ + "trusted" => scopes != [], + "trust_scopes" => scopes + } + end +end diff --git a/test/fixtures/user_level_plugins/superpowers.json b/test/fixtures/user_level_plugins/superpowers.json new file mode 100644 index 0000000..a190ddf --- /dev/null +++ b/test/fixtures/user_level_plugins/superpowers.json @@ -0,0 +1,62 @@ +{ + "plugins": [ + { + "id": "superpowers", + "name": "superpowers", + "version": "0.4.2", + "install_scope": "user", + "trust_scope": ["filesystem:read", "filesystem:write"], + "manifest_digest": "sha256:abc123def456", + "commands": [ + { + "name": "list", + "aliases": ["ls"], + "summary": "List installed Superpowers skills.", + "args": [], + "risk_class": "read_only", + "expected_artifacts": [], + "continuation_hint": "none" + }, + { + "name": "inspect", + "aliases": [], + "summary": "Print the manifest of one Superpowers skill.", + "args": [ + {"name": "skill", "required": true, "description": "Skill name."} + ], + "risk_class": "read_only", + "expected_artifacts": [], + "continuation_hint": "none" + }, + { + "name": "test-driven-development", + "aliases": ["tdd"], + "summary": "Generate a TDD handoff and seed for the given goal.", + "args": [ + {"name": "goal", "required": true, "description": "User goal."} + ], + "risk_class": "handoff_producing", + "expected_artifacts": [ + ".omx/superpowers/runs/*/seed.md", + ".omx/superpowers/runs/*/handoff.md" + ], + "continuation_hint": "suggest_run" + }, + { + "name": "systematic-debugging", + "aliases": ["debug"], + "summary": "Generate a debugging handoff and seed for the given goal.", + "args": [ + {"name": "goal", "required": true, "description": "User goal."} + ], + "risk_class": "handoff_producing", + "expected_artifacts": [ + ".omx/superpowers/runs/*/seed.md", + ".omx/superpowers/runs/*/handoff.md" + ], + "continuation_hint": "suggest_run" + } + ] + } + ] +} diff --git a/test/ourocode/plugin/user_level/capability_test.exs b/test/ourocode/plugin/user_level/capability_test.exs new file mode 100644 index 0000000..aeb3289 --- /dev/null +++ b/test/ourocode/plugin/user_level/capability_test.exs @@ -0,0 +1,138 @@ +defmodule Ourocode.Plugin.UserLevel.CapabilityTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + describe "new/1" do + test "builds a capability from a minimal valid descriptor" do + assert {:ok, %Capability{} = capability} = + Capability.new(%{plugin_id: "superpowers", source: :ouroboros_cli}) + + assert capability.plugin_id == "superpowers" + assert capability.plugin_name == "superpowers" + assert capability.source == :ouroboros_cli + assert capability.commands == [] + assert capability.trust_scope == [] + assert capability.install_scope == :unknown + assert %DateTime{} = capability.discovered_at + end + + test "normalizes commands and drops invalid ones" do + assert {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :ouroboros_cli, + commands: [ + %{name: "list"}, + %{name: ""}, + %{name: "tdd", aliases: ["test-driven-development"]} + ] + }) + + assert [%CommandCapability{name: "list"}, %CommandCapability{name: "tdd"} = tdd] = + capability.commands + + assert tdd.aliases == ["test-driven-development"] + end + + test "rejects missing plugin_id" do + assert {:error, :invalid_capability_attrs} = + Capability.new(%{source: :ouroboros_cli}) + end + + test "rejects unknown source" do + assert {:error, :invalid_capability_attrs} = + Capability.new(%{plugin_id: "x", source: :random}) + end + + test "uses caller-provided discovered_at when present" do + stamp = ~U[2026-01-01 00:00:00Z] + + assert {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + discovered_at: stamp + }) + + assert capability.discovered_at == stamp + end + + test "trust_scope drops blanks and dedupes" do + assert {:ok, capability} = + Capability.new(%{ + plugin_id: "x", + source: :ouroboros_cli, + trust_scope: ["filesystem:read", "", "filesystem:read", "filesystem:write"] + }) + + assert capability.trust_scope == ["filesystem:read", "filesystem:write"] + end + end + + describe "identity/1" do + test "is stable when plugin_id, version, and manifest_digest match" do + attrs = %{ + plugin_id: "superpowers", + source: :ouroboros_cli, + version: "0.4.2", + manifest_digest: "sha256:abc" + } + + {:ok, a} = Capability.new(attrs) + {:ok, b} = Capability.new(attrs) + + assert Capability.identity(a) == Capability.identity(b) + assert Capability.identity(a) == {"superpowers", "0.4.2", "sha256:abc"} + end + + test "differs when manifest digest changes" do + {:ok, a} = + Capability.new(%{ + plugin_id: "superpowers", + source: :ouroboros_cli, + manifest_digest: "sha256:old" + }) + + {:ok, b} = + Capability.new(%{ + plugin_id: "superpowers", + source: :ouroboros_cli, + manifest_digest: "sha256:new" + }) + + refute Capability.identity(a) == Capability.identity(b) + end + end + + describe "find_command/2" do + setup do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + commands: [ + %{name: "list"}, + %{name: "test-driven-development", aliases: ["tdd"]} + ] + }) + + %{capability: capability} + end + + test "matches canonical name", %{capability: capability} do + assert %CommandCapability{name: "test-driven-development"} = + Capability.find_command(capability, "test-driven-development") + end + + test "matches alias", %{capability: capability} do + assert %CommandCapability{name: "test-driven-development"} = + Capability.find_command(capability, "tdd") + end + + test "returns nil for unknown command", %{capability: capability} do + assert nil == Capability.find_command(capability, "nope") + end + end +end diff --git a/test/ourocode/plugin/user_level/discovery/ouroboros_cli_test.exs b/test/ourocode/plugin/user_level/discovery/ouroboros_cli_test.exs new file mode 100644 index 0000000..135770c --- /dev/null +++ b/test/ourocode/plugin/user_level/discovery/ouroboros_cli_test.exs @@ -0,0 +1,71 @@ +defmodule Ourocode.Plugin.UserLevel.Discovery.OuroborosCLITest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI + + @fixture_path Path.join([__DIR__, "..", "..", "..", "..", "fixtures", "user_level_plugins", "superpowers.json"]) + + test "parses the superpowers fixture into four commands" do + json = File.read!(@fixture_path) + + assert {:ok, [plugin]} = OuroborosCLI.parse(json) + + assert plugin.plugin_id == "superpowers" + assert plugin.version == "0.4.2" + assert plugin.install_scope == :user + assert plugin.trust_scope == ["filesystem:read", "filesystem:write"] + assert plugin.manifest_digest == "sha256:abc123def456" + + names = Enum.map(plugin.commands, & &1.name) + assert names == ["list", "inspect", "test-driven-development", "systematic-debugging"] + + tdd = Enum.find(plugin.commands, &(&1.name == "test-driven-development")) + assert tdd.aliases == ["tdd"] + assert tdd.risk_class == "handoff_producing" + + assert tdd.expected_artifacts == [ + ".omx/superpowers/runs/*/seed.md", + ".omx/superpowers/runs/*/handoff.md" + ] + end + + test "treats a bare JSON array (no plugins wrapper) the same way" do + json = ~s([{"id":"x","name":"x","commands":[]}]) + assert {:ok, [plugin]} = OuroborosCLI.parse(json) + assert plugin.plugin_id == "x" + end + + test "rejects malformed JSON" do + assert {:error, {:ouroboros_cli_invalid_json, _}} = + OuroborosCLI.parse("not-json") + end + + test "rejects unexpected JSON shape" do + assert {:error, :ouroboros_cli_unexpected_shape} = + OuroborosCLI.parse(~s({"unexpected": true})) + end + + test "runner failure surfaces as command_failed" do + runner = fn _cmd, _args, _opts -> + {:ok, %{status: 1, stdout: "", stderr: "ouroboros: not found"}} + end + + assert {:error, {:ouroboros_cli_failed, %{exit_status: 1, stderr: "ouroboros: not found"}}} = + OuroborosCLI.discover(command_runner: runner) + end + + test "runner error surfaces as unavailable" do + runner = fn _cmd, _args, _opts -> {:error, :enoent} end + + assert {:error, {:ouroboros_cli_unavailable, :enoent}} = + OuroborosCLI.discover(command_runner: runner) + end + + test "happy path runner returns parsed descriptors" do + json = File.read!(@fixture_path) + runner = fn _cmd, _args, _opts -> {:ok, %{status: 0, stdout: json, stderr: ""}} end + + assert {:ok, [plugin]} = OuroborosCLI.discover(command_runner: runner) + assert plugin.plugin_id == "superpowers" + end +end diff --git a/test/ourocode/plugin/user_level/discovery_test.exs b/test/ourocode/plugin/user_level/discovery_test.exs new file mode 100644 index 0000000..dd10427 --- /dev/null +++ b/test/ourocode/plugin/user_level/discovery_test.exs @@ -0,0 +1,47 @@ +defmodule Ourocode.Plugin.UserLevel.DiscoveryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Discovery + + defmodule StubAdapter do + @behaviour Ourocode.Plugin.UserLevel.Discovery + + @impl true + def discover(opts) do + Map.new(opts) |> Map.get(:stub_result, {:ok, []}) + end + end + + test "normalizes valid descriptors into Capability structs" do + descriptors = [ + %{plugin_id: "superpowers", source: :ouroboros_cli, commands: [%{name: "list"}]}, + %{plugin_id: "other", source: :ouroboros_cli} + ] + + assert {:ok, [a, b], []} = + Discovery.run(StubAdapter, stub_result: {:ok, descriptors}) + + assert %Capability{plugin_id: "superpowers", commands: [_command]} = a + assert %Capability{plugin_id: "other", commands: []} = b + end + + test "reports invalid descriptors without losing valid ones" do + descriptors = [ + %{plugin_id: "good", source: :ouroboros_cli}, + %{source: :ouroboros_cli}, + %{plugin_id: "another_good", source: :ouroboros_cli} + ] + + assert {:ok, capabilities, errors} = + Discovery.run(StubAdapter, stub_result: {:ok, descriptors}) + + assert Enum.map(capabilities, & &1.plugin_id) == ["good", "another_good"] + assert [{:invalid_descriptor, {:invalid_capability_attrs, %{source: :ouroboros_cli}}}] = errors + end + + test "propagates adapter errors unchanged" do + assert {:error, :boom} == + Discovery.run(StubAdapter, stub_result: {:error, :boom}) + end +end diff --git a/test/ourocode/plugin/user_level/registry_entry_test.exs b/test/ourocode/plugin/user_level/registry_entry_test.exs new file mode 100644 index 0000000..56703e6 --- /dev/null +++ b/test/ourocode/plugin/user_level/registry_entry_test.exs @@ -0,0 +1,98 @@ +defmodule Ourocode.Plugin.UserLevel.RegistryEntryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.RegistryEntry + + defp capability(opts \\ []) do + {:ok, capability} = + Capability.new(%{ + plugin_id: Keyword.get(opts, :plugin_id, "superpowers"), + source: :ouroboros_cli, + version: Keyword.get(opts, :version, "0.4.2"), + manifest_digest: Keyword.get(opts, :manifest_digest, "sha256:abc"), + trust_scope: Keyword.get(opts, :trust_scope, []), + commands: [ + %{ + name: "test-driven-development", + aliases: ["tdd"], + summary: "TDD handoff.", + args: [%{name: "goal", required: true, description: "User goal."}], + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_hint: "suggest_run" + } + ] + }) + + capability + end + + test "projects one entry per command capability" do + [entry] = RegistryEntry.entries(capability()) + + assert entry.id == "user_level_plugin:superpowers:test-driven-development" + assert entry.name == "superpowers test-driven-development" + assert entry.slash == "/superpowers test-driven-development" + assert entry.aliases == ["/superpowers tdd"] + assert entry.source == :plugin + assert entry.source_id == "superpowers" + assert entry.category == :plugins + assert entry.summary == "TDD handoff." + assert entry.availability == :available + assert entry.runnable? == true + + assert entry.args == [%{name: "goal", required?: true, description: "User goal."}] + + assert entry.run_spec.kind == :user_level_plugin_command + assert entry.run_spec.plugin_id == "superpowers" + assert entry.run_spec.command == "test-driven-development" + assert entry.run_spec.risk_class == :handoff_producing + assert entry.run_spec.continuation_hint == :suggest_run + + assert entry.metadata.plugin_id == "superpowers" + assert entry.metadata.plugin_surface == :user_level + assert entry.metadata.namespace_owner == :ouroboros + assert entry.metadata.capability_version == "0.4.2" + assert entry.metadata.manifest_digest == "sha256:abc" + end + + test "trust metadata defaults to requires_explicit_approval when no scopes are present" do + [entry] = RegistryEntry.entries(capability(trust_scope: [])) + + assert entry.metadata.trust_policy == %{ + "tier" => "user_level", + "requires_explicit_approval" => true + } + + assert entry.metadata.trust_evaluation == %{ + "trusted" => false, + "trust_scopes" => [] + } + end + + test "trust metadata reflects discovered trust scopes" do + scopes = ["filesystem:read", "filesystem:write"] + [entry] = RegistryEntry.entries(capability(trust_scope: scopes)) + + assert entry.metadata.trust_policy == %{ + "tier" => "user_level", + "requires_explicit_approval" => false, + "trust_scopes" => scopes + } + + assert entry.metadata.trust_evaluation == %{ + "trusted" => true, + "trust_scopes" => scopes + } + end + + test "entries/1 flattens a list of capabilities" do + one = capability(plugin_id: "a", manifest_digest: "sha256:a") + two = capability(plugin_id: "b", manifest_digest: "sha256:b") + + entries = RegistryEntry.entries([one, two]) + assert length(entries) == 2 + assert Enum.map(entries, & &1.source_id) == ["a", "b"] + end +end diff --git a/test/ourocode/plugin/user_level/registry_test.exs b/test/ourocode/plugin/user_level/registry_test.exs new file mode 100644 index 0000000..f4c3363 --- /dev/null +++ b/test/ourocode/plugin/user_level/registry_test.exs @@ -0,0 +1,117 @@ +defmodule Ourocode.Plugin.UserLevel.RegistryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Registry + + defmodule StubAdapter do + @behaviour Ourocode.Plugin.UserLevel.Discovery + + @impl true + def discover(opts) do + Map.new(opts) |> Map.get(:stub_result, {:ok, []}) + end + end + + setup do + name = :"user_level_registry_#{System.unique_integer([:positive])}" + {:ok, _pid} = Registry.start_link(name: name, adapter: StubAdapter) + %{registry: name} + end + + test "starts in :empty status with no capabilities", %{registry: registry} do + snapshot = Registry.list(registry, max_age_ms: nil) + assert snapshot.status == :empty + assert snapshot.capabilities == [] + assert snapshot.refreshed_at == nil + end + + test "refresh/2 with successful discovery returns :ready snapshot", %{registry: registry} do + descriptors = [%{plugin_id: "superpowers", source: :fixture, version: "1.0.0"}] + + snapshot = + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + assert snapshot.status == :ready + assert [%Capability{plugin_id: "superpowers"}] = snapshot.capabilities + assert %DateTime{} = snapshot.refreshed_at + assert snapshot.errors == [] + end + + test "refresh/2 surface adapter errors as :degraded snapshot", %{registry: registry} do + snapshot = + Registry.refresh(registry, adapter_options: [stub_result: {:error, :boom}]) + + assert snapshot.status == :degraded + assert snapshot.capabilities == [] + assert [{:discovery_failed, :boom}] = snapshot.errors + assert %DateTime{} = snapshot.refreshed_at + end + + test "refresh/2 preserves last good capabilities on subsequent failure", %{registry: registry} do + descriptors = [%{plugin_id: "superpowers", source: :fixture}] + + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + after_failure = + Registry.refresh(registry, adapter_options: [stub_result: {:error, :network}]) + + assert after_failure.status == :degraded + assert [%Capability{plugin_id: "superpowers"}] = after_failure.capabilities + assert [{:discovery_failed, :network} | _] = after_failure.errors + end + + test "identity stability: same struct instance is returned across refreshes", %{ + registry: registry + } do + descriptors = [ + %{ + plugin_id: "superpowers", + source: :fixture, + version: "0.4.2", + manifest_digest: "sha256:abc" + } + ] + + %{capabilities: [first]} = + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + %{capabilities: [second]} = + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + # Same identity -> reused struct + assert first == second + assert Capability.identity(first) == Capability.identity(second) + end + + test "list/2 with TTL=0 triggers a refresh using the cached adapter options", + %{registry: registry} do + descriptors = [%{plugin_id: "x", source: :fixture}] + + # Seed the adapter options once via an explicit refresh. + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + # max_age_ms: 0 forces stale; list/2 must re-run discovery with the + # adapter options it was configured with. + snapshot = Registry.list(registry, max_age_ms: 0) + assert [%Capability{plugin_id: "x"}] = snapshot.capabilities + + # max_age_ms: nil returns the cached snapshot unchanged. + cached = Registry.list(registry, max_age_ms: nil) + assert cached.refreshed_at == snapshot.refreshed_at + end + + test "fetch/2 returns capability by plugin_id from cached snapshot", %{registry: registry} do + descriptors = [ + %{plugin_id: "superpowers", source: :fixture}, + %{plugin_id: "other", source: :fixture} + ] + + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + assert {:ok, %Capability{plugin_id: "superpowers"}} = + Registry.fetch(registry, "superpowers") + + assert :error == Registry.fetch(registry, "unknown") + end +end From a564bd5b34745d9474828f8e082c46a2ad6294b8 Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Mon, 25 May 2026 17:10:02 +0900 Subject: [PATCH 3/7] feat(plugin): add UserLevel plugin preflight resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pure resolver from `ooo [args ...]`-shaped input to a structured PreflightResult. The result is read-only: it describes what dispatch would do without executing, mutating trust, or touching the registry. What is added: - Ourocode.Plugin.UserLevel.PreflightResult Struct with kind (:unique_match | :ambiguous | :unknown | :not_applicable), task_input, matched plugin/command, parsed argv tail, trust state, remediation string, risk class, expected artifacts, continuation policy, candidates (ambiguous case), and a match_explanation (matched_by + confidence). - Ourocode.Plugin.UserLevel.Resolver resolve/2 turns task_input + capabilities into a PreflightResult. applies_to?/2 is the cheap predicate routing layers call to decide whether to swap their routing decision before invoking dispatch. Resolution rules (intentionally narrow): * Direct ooo/ouroboros prefix only. Free-form natural language is deferred until the exact path is stable. * Plugin id and command name/alias matching is exact (case insensitive at lookup time but capability fields are preserved). * Argument tokens are passed through verbatim — no shell parsing, no case folding. Shell injection input becomes argv tokens that Dispatcher.guarded_external_command_runner will still guard. * Duplicate plugin ids surface as :ambiguous with candidates, never silent guess. * Trust state: :allowed when the capability declares trust_scope, :missing otherwise (with a remediation suggestion that points the user at `ouroboros plugin trust ...`). What is intentionally NOT in this PR: - Router or Dispatcher route additions. Those land in PR 4 together with the UserLevelPluginInvocation adapter, so dispatch can be tested end-to-end in one place. - TUI panel rendering. PR 4 ships PreflightPanel. - Decision journal. PR 5. Tests: 2 ExUnit modules (async: true) covering unique_match (canonical + alias + mixed-case + arg case preservation + shell injection input), trust missing, unknown plugin/command, missing tokens, ambiguous duplicate ids, not_applicable inputs, and applies_to? predicate. Closes #16 Closes #23 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugin/user_level/preflight_result.ex | 72 +++++ lib/ourocode/plugin/user_level/resolver.ex | 263 ++++++++++++++++++ .../user_level/preflight_result_test.exs | 25 ++ .../plugin/user_level/resolver_test.exs | 216 ++++++++++++++ 4 files changed, 576 insertions(+) create mode 100644 lib/ourocode/plugin/user_level/preflight_result.ex create mode 100644 lib/ourocode/plugin/user_level/resolver.ex create mode 100644 test/ourocode/plugin/user_level/preflight_result_test.exs create mode 100644 test/ourocode/plugin/user_level/resolver_test.exs diff --git a/lib/ourocode/plugin/user_level/preflight_result.ex b/lib/ourocode/plugin/user_level/preflight_result.ex new file mode 100644 index 0000000..0864f82 --- /dev/null +++ b/lib/ourocode/plugin/user_level/preflight_result.ex @@ -0,0 +1,72 @@ +defmodule Ourocode.Plugin.UserLevel.PreflightResult do + @moduledoc """ + Read-only resolution result for an `ooo ...`-shaped + prompt. + + A `PreflightResult` records *what would happen* if dispatch proceeded: + which UserLevel plugin and command were matched, which arguments were + parsed, what trust state applies, what artifacts the command is expected + to produce, and which continuation policy governs follow-up workflows. + + The result itself never executes anything. The dispatch adapter consumes + the result; the TUI renders it; the journal records it. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @type kind :: + :unique_match + | :ambiguous + | :unknown + | :not_applicable + + @type trust_state :: :allowed | :missing | :unknown + @type continuation_policy :: :none | :suggest | :auto_when_requested + @type confidence :: :exact | :alias | :none + + @type match_explanation :: %{ + required(:matched_by) => :canonical | :alias | nil, + required(:confidence) => confidence(), + optional(:reason) => atom() + } + + @type t :: %__MODULE__{ + kind: kind(), + task_input: String.t(), + plugin: Capability.t() | nil, + command: CommandCapability.t() | nil, + args: [String.t()], + trust_state: trust_state(), + remediation: String.t() | nil, + risk_class: CommandCapability.risk_class(), + expected_artifacts: [String.t()], + continuation_policy: continuation_policy(), + candidates: [Capability.t()], + match_explanation: match_explanation(), + reason: atom() | nil + } + + defstruct kind: :unknown, + task_input: "", + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: nil + + @doc """ + Convenience constructor used by the resolver to ensure default fields stay + consistent across kinds. + """ + @spec new(keyword()) :: t() + def new(fields) when is_list(fields) do + struct(__MODULE__, fields) + end +end diff --git a/lib/ourocode/plugin/user_level/resolver.ex b/lib/ourocode/plugin/user_level/resolver.ex new file mode 100644 index 0000000..ead1b5f --- /dev/null +++ b/lib/ourocode/plugin/user_level/resolver.ex @@ -0,0 +1,263 @@ +defmodule Ourocode.Plugin.UserLevel.Resolver do + @moduledoc """ + Pure resolver from `ooo [args ...]`-shaped input to a + `Ourocode.Plugin.UserLevel.PreflightResult`. + + The resolver is intentionally narrow: + + * Direct command form only — the first token must be `ooo` or + `ouroboros`. Free-form natural language is deferred until the exact + path is stable. + * Exact match only on plugin id and command name/alias. Fuzzy matching + is explicitly out of scope; ambiguity surfaces as `:ambiguous` with + candidate plugins rather than as a guess. + * No execution, no trust mutation. The resolver only describes what a + dispatch step *would* do. + + Trust mapping: + + * A capability whose `trust_scope` is non-empty is considered + `:allowed`. The Ouroboros plugin list is the source of truth — if + Ouroboros declares scopes, the user has granted them. + * A capability whose `trust_scope` is empty surfaces as `:missing` + with a remediation string suggesting `ouroboros plugin trust ...`. + Granting trust remains an Ouroboros responsibility. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + alias Ourocode.Plugin.UserLevel.PreflightResult + + @ooo_prefixes ["ooo", "ouroboros"] + + @doc """ + Resolves `task_input` against the given capability list. + + Returns a `PreflightResult`. The resolver never raises and never mutates + capabilities; callers can pass a registry snapshot directly. + """ + @spec resolve(String.t(), [Capability.t()]) :: PreflightResult.t() + def resolve(task_input, capabilities) when is_binary(task_input) and is_list(capabilities) do + trimmed = String.trim(task_input) + + case tokenize(trimmed) do + [prefix, plugin_token | rest] -> + if ooo_prefix?(prefix) do + resolve_plugin(trimmed, plugin_token, rest, capabilities) + else + not_applicable(trimmed) + end + + [prefix] -> + if ooo_prefix?(prefix) do + unknown(trimmed, :missing_plugin_token) + else + not_applicable(trimmed) + end + + [] -> + not_applicable(trimmed) + end + end + + def resolve(_task_input, _capabilities), do: not_applicable("") + + defp resolve_plugin(input, plugin_token, rest, capabilities) do + normalized = String.downcase(plugin_token) + + case Enum.filter(capabilities, &(String.downcase(&1.plugin_id) == normalized)) do + [] -> + unknown(input, :unknown_plugin) + + [capability] -> + resolve_command(input, capability, rest) + + [_ | _] = matches -> + ambiguous(input, matches) + end + end + + defp resolve_command(input, capability, []) do + %PreflightResult{ + kind: :unknown, + task_input: input, + plugin: capability, + command: nil, + args: [], + trust_state: trust_state(capability), + remediation: remediation_for(capability), + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :missing_command_token + } + end + + defp resolve_command(input, capability, [command_token | args]) do + normalized = String.downcase(command_token) + + case find_command_ci(capability, normalized) do + nil -> + %PreflightResult{ + kind: :unknown, + task_input: input, + plugin: capability, + command: nil, + args: args, + trust_state: trust_state(capability), + remediation: remediation_for(capability), + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :unknown_command + } + + %CommandCapability{} = command -> + unique_match(input, capability, command, normalized, args) + end + end + + defp find_command_ci(%Capability{commands: commands}, normalized_token) do + Enum.find(commands, fn cmd -> + String.downcase(cmd.name) == normalized_token or + normalized_token in Enum.map(cmd.aliases, &String.downcase/1) + end) + end + + defp unique_match(input, capability, command, normalized_token, args) do + confidence = + cond do + String.downcase(command.name) == normalized_token -> :exact + normalized_token in Enum.map(command.aliases, &String.downcase/1) -> :alias + true -> :none + end + + matched_by = + cond do + String.downcase(command.name) == normalized_token -> :canonical + normalized_token in Enum.map(command.aliases, &String.downcase/1) -> :alias + true -> nil + end + + %PreflightResult{ + kind: :unique_match, + task_input: input, + plugin: capability, + command: command, + args: args, + trust_state: trust_state(capability), + remediation: remediation_for(capability), + risk_class: command.risk_class, + expected_artifacts: command.expected_artifacts, + continuation_policy: continuation_policy_for(command), + candidates: [], + match_explanation: %{matched_by: matched_by, confidence: confidence}, + reason: nil + } + end + + defp ambiguous(input, candidates) do + %PreflightResult{ + kind: :ambiguous, + task_input: input, + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: candidates, + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :duplicate_plugin_ids + } + end + + defp unknown(input, reason) do + %PreflightResult{ + kind: :unknown, + task_input: input, + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: reason + } + end + + defp not_applicable(input) do + %PreflightResult{ + kind: :not_applicable, + task_input: input, + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :not_user_level_plugin_input + } + end + + @doc """ + Convenience predicate for routing layers: returns `true` when `task_input` + syntactically targets a UserLevel plugin known to `capabilities`. + + Does not evaluate trust or command validity; that is the job of `resolve/2`. + Callers can use this to decide whether to swap the routing decision before + invoking the dispatcher. + """ + @spec applies_to?(String.t(), [Capability.t()]) :: boolean() + def applies_to?(task_input, capabilities) + when is_binary(task_input) and is_list(capabilities) do + case tokenize(String.trim(task_input)) do + [prefix, plugin_token | _rest] -> + if ooo_prefix?(prefix) do + normalized = String.downcase(plugin_token) + Enum.any?(capabilities, &(String.downcase(&1.plugin_id) == normalized)) + else + false + end + + _other -> + false + end + end + + def applies_to?(_task_input, _capabilities), do: false + + defp tokenize(""), do: [] + defp tokenize(input), do: String.split(input, ~r/\s+/u, trim: true) + + defp ooo_prefix?(prefix), do: String.downcase(prefix) in @ooo_prefixes + + defp trust_state(%Capability{trust_scope: scopes}) when scopes != [], do: :allowed + defp trust_state(%Capability{}), do: :missing + + defp remediation_for(%Capability{trust_scope: scopes, plugin_id: id}) when scopes == [] do + "ouroboros plugin trust #{id} --scope " + end + + defp remediation_for(_capability), do: nil + + defp continuation_policy_for(%CommandCapability{continuation_hint: :auto_run_when_requested}), + do: :auto_when_requested + + defp continuation_policy_for(%CommandCapability{continuation_hint: :suggest_run}), do: :suggest + defp continuation_policy_for(%CommandCapability{}), do: :none +end diff --git a/test/ourocode/plugin/user_level/preflight_result_test.exs b/test/ourocode/plugin/user_level/preflight_result_test.exs new file mode 100644 index 0000000..421989b --- /dev/null +++ b/test/ourocode/plugin/user_level/preflight_result_test.exs @@ -0,0 +1,25 @@ +defmodule Ourocode.Plugin.UserLevel.PreflightResultTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.PreflightResult + + test "new/1 fills defaults for unspecified fields" do + result = PreflightResult.new(kind: :unknown, task_input: "x") + + assert %PreflightResult{ + kind: :unknown, + task_input: "x", + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: nil + } = result + end +end diff --git a/test/ourocode/plugin/user_level/resolver_test.exs b/test/ourocode/plugin/user_level/resolver_test.exs new file mode 100644 index 0000000..0ca0052 --- /dev/null +++ b/test/ourocode/plugin/user_level/resolver_test.exs @@ -0,0 +1,216 @@ +defmodule Ourocode.Plugin.UserLevel.ResolverTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Plugin.UserLevel.Resolver + + defp superpowers(opts \\ []) do + {:ok, cap} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + version: "0.4.2", + manifest_digest: "sha256:abc", + trust_scope: Keyword.get(opts, :trust_scope, ["filesystem:read", "filesystem:write"]), + commands: [ + %{name: "list", aliases: ["ls"], risk_class: "read_only"}, + %{ + name: "test-driven-development", + aliases: ["tdd"], + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_hint: "suggest_run" + } + ] + }) + + cap + end + + describe "resolve/2 — unique match" do + test "exact canonical command match returns :unique_match with confidence :exact" do + result = + Resolver.resolve( + "ooo superpowers test-driven-development --goal retry", + [superpowers()] + ) + + assert %PreflightResult{ + kind: :unique_match, + plugin: %Capability{plugin_id: "superpowers"}, + trust_state: :allowed, + risk_class: :handoff_producing, + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_policy: :suggest, + match_explanation: %{matched_by: :canonical, confidence: :exact} + } = result + + assert result.command.name == "test-driven-development" + assert result.args == ["--goal", "retry"] + assert result.reason == nil + end + + test "alias matches with confidence :alias" do + result = Resolver.resolve("ooo superpowers tdd --goal x", [superpowers()]) + + assert %PreflightResult{ + kind: :unique_match, + match_explanation: %{matched_by: :alias, confidence: :alias} + } = result + + assert result.command.name == "test-driven-development" + end + + test "treats `ouroboros` prefix identically to `ooo`" do + result = + Resolver.resolve("ouroboros superpowers tdd --goal x", [superpowers()]) + + assert %PreflightResult{kind: :unique_match} = result + end + + test "preserves argument casing verbatim" do + result = + Resolver.resolve( + "ooo superpowers tdd --Goal MixedCase --Verbose", + [superpowers()] + ) + + assert %PreflightResult{kind: :unique_match, args: args} = result + assert "--Goal" in args + assert "MixedCase" in args + assert "--Verbose" in args + end + + test "matches plugin and command case-insensitively even when typed in mixed case" do + result = Resolver.resolve("OOO Superpowers TDD --goal x", [superpowers()]) + + assert %PreflightResult{ + kind: :unique_match, + match_explanation: %{matched_by: :alias} + } = result + + assert result.command.name == "test-driven-development" + end + + test "preserves shell-injection-like arg tokens as argv (no shell parsing)" do + result = + Resolver.resolve( + ~s(ooo superpowers tdd --goal "; rm -rf /"), + [superpowers()] + ) + + assert result.kind == :unique_match + # tokenization is whitespace-only; quoting is not honored. The point is + # that no shell expansion happens here. + assert "--goal" in result.args + refute Enum.any?(result.args, &String.contains?(&1, "$(")) + end + end + + describe "resolve/2 — trust missing" do + test "capability without trust_scope returns :allowed=false and remediation" do + result = + Resolver.resolve( + "ooo superpowers list", + [superpowers(trust_scope: [])] + ) + + assert %PreflightResult{ + kind: :unique_match, + trust_state: :missing, + remediation: "ouroboros plugin trust superpowers --scope " + } = result + end + end + + describe "resolve/2 — unknown" do + test "unknown plugin returns :unknown with :unknown_plugin reason" do + result = Resolver.resolve("ooo unknownplug tdd", [superpowers()]) + + assert %PreflightResult{ + kind: :unknown, + reason: :unknown_plugin, + plugin: nil, + command: nil + } = result + end + + test "known plugin with unknown command returns :unknown with :unknown_command reason" do + result = Resolver.resolve("ooo superpowers nope", [superpowers()]) + + assert %PreflightResult{ + kind: :unknown, + reason: :unknown_command, + plugin: %Capability{plugin_id: "superpowers"}, + command: nil + } = result + end + + test "missing command token returns :missing_command_token" do + result = Resolver.resolve("ooo superpowers", [superpowers()]) + + assert %PreflightResult{ + kind: :unknown, + reason: :missing_command_token, + plugin: %Capability{plugin_id: "superpowers"} + } = result + end + + test "missing plugin token returns :missing_plugin_token" do + result = Resolver.resolve("ooo", [superpowers()]) + + assert %PreflightResult{kind: :unknown, reason: :missing_plugin_token} = result + end + end + + describe "resolve/2 — ambiguous" do + test "duplicate plugin_ids return :ambiguous with candidates" do + cap_a = superpowers() + {:ok, cap_b} = Capability.new(%{plugin_id: "superpowers", source: :fixture}) + + result = Resolver.resolve("ooo superpowers list", [cap_a, cap_b]) + + assert %PreflightResult{ + kind: :ambiguous, + reason: :duplicate_plugin_ids + } = result + + assert length(result.candidates) == 2 + end + end + + describe "resolve/2 — not applicable" do + test "non-ooo input returns :not_applicable" do + result = Resolver.resolve("interview some goal", [superpowers()]) + + assert %PreflightResult{ + kind: :not_applicable, + reason: :not_user_level_plugin_input + } = result + end + + test "blank input returns :not_applicable" do + result = Resolver.resolve(" ", [superpowers()]) + assert result.kind == :not_applicable + end + end + + describe "applies_to?/2" do + test "true when ooo + known plugin id" do + assert Resolver.applies_to?("ooo superpowers list", [superpowers()]) + end + + test "false when ooo + unknown plugin id" do + refute Resolver.applies_to?("ooo other list", [superpowers()]) + end + + test "false for non-ooo input" do + refute Resolver.applies_to?("interview x", [superpowers()]) + end + + test "false for blank input" do + refute Resolver.applies_to?("", [superpowers()]) + end + end +end From 5d8c61cc08a01a8a792befdda8db711cbd7bedb7 Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Mon, 25 May 2026 17:15:26 +0900 Subject: [PATCH 4/7] feat(plugin): add UserLevel plugin dispatch adapter and route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the UserLevel plugin layer into the runtime dispatch contract so `ooo ` input can be routed through the existing guarded external command runner without bypassing trust, risk class, or shell-injection protection. What is added: - Ourocode.Runtime.UserLevelPluginInvocation Implements the runtime Adapter behaviour. Reads :capabilities from the dispatch context, runs the Resolver, evaluates trust state and risk class, and either invokes via :external_command_runner or returns a structured :blocked result. Argv is always a list — the Dispatcher's guarded_external_command_runner stays the shell-injection authority. Blocked reasons surfaced: :trust_missing, :ambiguous_match, :unknown_plugin_or_command, :destructive_action_requires_approval, :not_user_level_plugin_input, {:external_command_failed, reason} Destructive risk_class commands require explicit context.destructive_action_approved? = true. Default fails closed. - Ourocode.Plugin.UserLevel.PreflightView JSON-safe projection of a PreflightResult shaped like the existing Ourocode.Command.CapabilityPreflight.Projection. Lets any UI render UserLevel plugin preflight using the same shape as the slash command preflight (trust, side_effects, candidates, match_explanation). - Ourocode.Plugin.UserLevel.Entry Router refinement helper. Takes a parsed TaskRequest and a capability snapshot; when the input targets a known UserLevel plugin, swaps the routing_decision to :user_level_plugin and attaches plugin_id. Keeps Ourocode.Runtime.Router itself transport- and registry-agnostic. - Ourocode.Runtime.Dispatcher.RouteResolution Adds :user_level_plugin to @supported_routes, adds adapter_keys/1 clauses (plugin-id-scoped + generic fallback), reuses existing validate_adapter_route guard to reject decisions that incorrectly carry adapter_route. - Ourocode.TaskRequest routing_decision type extended with :user_level_plugin kind / execution_route and optional :plugin_id field. What is intentionally NOT in this PR: - Registry supervision wiring in ApplicationServices. Until the live TUI integration ships, callers pass capability snapshots directly via context. PR 5 wires the registry into the runtime supervision tree alongside the artifact watcher. - /plugins refresh slash command. Ships with PR 5 once the registry is supervised. - Continuation/auto-run policy and artifact detection. PR 5. Tests: 4 ExUnit modules (async: true) — adapter happy path, trust blocked, unknown command blocked, destructive blocked + approved, shell-injection argv passthrough, runner contract errors, view projection across kinds, entry refinement (rewrites only when applicable), and dispatcher route validation + adapter_keys. Closes #15 Closes #17 (minimal — trust-blocked structured error path) Closes #20 Closes #21 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/ourocode/plugin/user_level/entry.ex | 74 ++++++ .../plugin/user_level/preflight_view.ex | 76 ++++++ .../runtime/dispatcher/route_resolution.ex | 11 +- .../runtime/user_level_plugin_invocation.ex | 191 ++++++++++++++ lib/ourocode/task_request.ex | 8 +- .../ourocode/plugin/user_level/entry_test.exs | 74 ++++++ .../plugin/user_level/preflight_view_test.exs | 94 +++++++ .../route_resolution_user_level_test.exs | 63 +++++ .../user_level_plugin_invocation_test.exs | 235 ++++++++++++++++++ 9 files changed, 822 insertions(+), 4 deletions(-) create mode 100644 lib/ourocode/plugin/user_level/entry.ex create mode 100644 lib/ourocode/plugin/user_level/preflight_view.ex create mode 100644 lib/ourocode/runtime/user_level_plugin_invocation.ex create mode 100644 test/ourocode/plugin/user_level/entry_test.exs create mode 100644 test/ourocode/plugin/user_level/preflight_view_test.exs create mode 100644 test/ourocode/runtime/dispatcher/route_resolution_user_level_test.exs create mode 100644 test/ourocode/runtime/user_level_plugin_invocation_test.exs diff --git a/lib/ourocode/plugin/user_level/entry.ex b/lib/ourocode/plugin/user_level/entry.ex new file mode 100644 index 0000000..ced9d55 --- /dev/null +++ b/lib/ourocode/plugin/user_level/entry.ex @@ -0,0 +1,74 @@ +defmodule Ourocode.Plugin.UserLevel.Entry do + @moduledoc """ + Entry point that the runtime calls to decide whether an incoming + `TaskRequest` should be routed to the UserLevel plugin adapter. + + This is the small router refinement step that keeps + `Ourocode.Runtime.Router` itself transport- and registry-agnostic: + + * Router does coarse classification (`ooo`/`ouroboros` → ouroboros + workflow + adapter_route). + * `Entry.refine/2` looks at the resolved capability list and, if the + task input targets a known UserLevel plugin, swaps the routing + decision to `:user_level_plugin` and attaches the plugin_id. + + The runtime call site does: + + task_request + |> Entry.refine(capabilities) + |> Dispatcher.dispatch(adapters: adapters, context: context) + + No execution happens here; this is decision data only. + """ + + alias Ourocode.Plugin.UserLevel.Resolver + alias Ourocode.TaskRequest + + @doc """ + Returns a TaskRequest whose routing_decision is rewritten to + `:user_level_plugin` when the input targets a known plugin; otherwise + returns the original TaskRequest unchanged. + + The refined routing_decision carries: + + * `kind` and `execution_route` set to `:user_level_plugin` + * `runtime_source: :ouroboros` + * `transport: :auto` + * `plugin_id` — the matched plugin id + * `reason: :user_level_plugin_resolved` + """ + @spec refine(TaskRequest.t(), [Ourocode.Plugin.UserLevel.Capability.t()]) :: TaskRequest.t() + def refine(%TaskRequest{task_input: input} = task_request, capabilities) + when is_list(capabilities) do + if Resolver.applies_to?(input, capabilities) do + plugin_id = plugin_id_from_input(input) + + routing_decision = %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: plugin_id + } + + %{task_request | routing_decision: routing_decision} + else + task_request + end + end + + def refine(task_request, _capabilities), do: task_request + + defp plugin_id_from_input(input) do + input + |> String.trim() + |> String.split(~r/\s+/u, trim: true) + |> case do + [_prefix, plugin_token | _rest] -> String.downcase(plugin_token) + _other -> nil + end + end +end diff --git a/lib/ourocode/plugin/user_level/preflight_view.ex b/lib/ourocode/plugin/user_level/preflight_view.ex new file mode 100644 index 0000000..7bba07d --- /dev/null +++ b/lib/ourocode/plugin/user_level/preflight_view.ex @@ -0,0 +1,76 @@ +defmodule Ourocode.Plugin.UserLevel.PreflightView do + @moduledoc """ + JSON-safe projection of a `Ourocode.Plugin.UserLevel.PreflightResult` for + TUIs, dashboards, and decision journals. + + The projection deliberately mirrors the field shape used by + `Ourocode.Command.CapabilityPreflight.Projection` so any UI that already + renders the slash-command preflight can render UserLevel plugin + preflight without a separate code path. + + No execution, no trust mutation, no plugin-internal paths. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + alias Ourocode.Plugin.UserLevel.PreflightResult + + @doc """ + Projects a `PreflightResult` into a JSON-safe map. + """ + @spec project(PreflightResult.t()) :: map() + def project(%PreflightResult{} = result) do + %{ + kind: result.kind, + task_input: result.task_input, + reason: result.reason, + plugin: plugin_view(result.plugin), + command: command_view(result.command), + args: result.args, + trust: %{ + state: result.trust_state, + remediation: result.remediation + }, + side_effects: %{ + execution: execution_class(result.kind, result.command), + discovery: :read_only, + risk_class: result.risk_class, + expected_artifacts: result.expected_artifacts, + continuation_policy: result.continuation_policy + }, + candidates: Enum.map(result.candidates, &plugin_view/1), + match_explanation: result.match_explanation + } + end + + defp plugin_view(nil), do: nil + + defp plugin_view(%Capability{} = capability) do + %{ + plugin_id: capability.plugin_id, + plugin_name: capability.plugin_name, + source: capability.source, + version: capability.version, + install_scope: capability.install_scope, + trust_scope: capability.trust_scope, + manifest_digest: capability.manifest_digest + } + end + + defp command_view(nil), do: nil + + defp command_view(%CommandCapability{} = command) do + %{ + name: command.name, + aliases: command.aliases, + summary: command.summary, + args: command.args, + risk_class: command.risk_class, + expected_artifacts: command.expected_artifacts, + continuation_hint: command.continuation_hint + } + end + + defp execution_class(:unique_match, %CommandCapability{}), do: :pending_approval + defp execution_class(_kind, _command), do: :blocked +end diff --git a/lib/ourocode/runtime/dispatcher/route_resolution.ex b/lib/ourocode/runtime/dispatcher/route_resolution.ex index a34838f..527903a 100644 --- a/lib/ourocode/runtime/dispatcher/route_resolution.ex +++ b/lib/ourocode/runtime/dispatcher/route_resolution.ex @@ -3,7 +3,7 @@ defmodule Ourocode.Runtime.Dispatcher.RouteResolution do alias Ourocode.TaskRequest - @supported_routes [:runtime, :ouroboros_workflow, :mcp_flow] + @supported_routes [:runtime, :ouroboros_workflow, :mcp_flow, :user_level_plugin] @supported_runtime_sources [:auto, :codex, :opencode, :claude_code, :ouroboros, :mcp] @supported_transports [:auto, :stdio, :streamable_http, :sse] @supported_adapter_routes [ @@ -108,6 +108,15 @@ defmodule Ourocode.Runtime.Dispatcher.RouteResolution do ] end + def adapter_keys(%{execution_route: :user_level_plugin, plugin_id: plugin_id}) + when is_binary(plugin_id) and plugin_id != "" do + [{:user_level_plugin, plugin_id}, :user_level_plugin] + end + + def adapter_keys(%{execution_route: :user_level_plugin}) do + [:user_level_plugin] + end + def adapter_keys(%{execution_route: route, runtime_source: runtime_source}) do adapter_keys(route, runtime_source) end diff --git a/lib/ourocode/runtime/user_level_plugin_invocation.ex b/lib/ourocode/runtime/user_level_plugin_invocation.ex new file mode 100644 index 0000000..395b5d5 --- /dev/null +++ b/lib/ourocode/runtime/user_level_plugin_invocation.ex @@ -0,0 +1,191 @@ +defmodule Ourocode.Runtime.UserLevelPluginInvocation do + @moduledoc """ + Runtime adapter for `:user_level_plugin` execution routes. + + Takes a `TaskRequest` whose routing decision references an installed + Ouroboros UserLevel plugin, resolves the request against a capability + list, and dispatches the matched command through the guarded external + command runner installed by `Ourocode.Runtime.Dispatcher`. + + Safety rules (the entire point of this module): + + * The adapter never executes anything when the preflight result is not + `:unique_match`. Ambiguous, unknown, or not-applicable results return + a structured error. + * The adapter never executes when `trust_state` is anything other than + `:allowed`. The structured error includes the remediation string so + the UI can render it verbatim. + * The adapter never executes commands whose declared `risk_class` is + `:destructive` unless the dispatch context explicitly carries + `destructive_action_approved?: true`. Future trust UX can flip this + flag; until then destructive actions are blocked closed. + * Arguments are passed through as an argv list (never assembled into a + shell string). The Dispatcher's `guarded_external_command_runner` + enforces the forbidden-command rules regardless. + + Context inputs (all optional unless noted): + + * `:capabilities` (required) — list of `Ourocode.Plugin.UserLevel.Capability` + structs. The adapter resolves against this snapshot rather than + reaching into a live registry, so unit tests can pass fixture data. + * `:external_command_runner` (set by Dispatcher) — guarded runner the + adapter must use. + * `:cwd` — working directory passed to the runner. + * `:env` — environment overlay for the runner. + * `:command` — override the executable name (defaults to `"ouroboros"`). + * `:destructive_action_approved?` — explicit approval flag for + destructive risk_class commands. + + Result envelope: + + %{ + type: :user_level_plugin_invocation, + status: :invoked | :blocked, + task_request_id: String.t(), + preflight: PreflightResult.t(), + argv: [String.t()], + command: String.t(), + execution: %{...} | nil, + blocked_reason: atom() | nil + } + """ + + @behaviour Ourocode.Runtime.Adapter + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Plugin.UserLevel.Resolver + alias Ourocode.TaskRequest + + @default_command "ouroboros" + + @type invocation :: %{ + required(:type) => :user_level_plugin_invocation, + required(:status) => :invoked | :blocked, + required(:task_request_id) => String.t(), + required(:preflight) => PreflightResult.t(), + required(:argv) => [String.t()], + required(:command) => String.t(), + optional(:execution) => map(), + optional(:blocked_reason) => atom() + } + + @impl true + @spec execute(TaskRequest.t(), map()) :: {:ok, invocation()} | {:error, term()} + def execute(%TaskRequest{} = task_request, context) when is_map(context) do + with {:ok, capabilities} <- fetch_capabilities(context), + %PreflightResult{} = preflight <- + Resolver.resolve(task_request.task_input, capabilities) do + preflight + |> evaluate(context) + |> finalize(task_request, preflight, context) + end + end + + def execute(_task_request, _context), do: {:error, :invalid_task_request} + + defp fetch_capabilities(context) do + case Map.get(context, :capabilities) do + capabilities when is_list(capabilities) -> + if Enum.all?(capabilities, &match?(%Capability{}, &1)) do + {:ok, capabilities} + else + {:error, :invalid_capabilities_in_context} + end + + nil -> + {:error, :capabilities_required_in_context} + + _other -> + {:error, :invalid_capabilities_in_context} + end + end + + defp evaluate(%PreflightResult{kind: :unique_match} = preflight, context) do + cond do + preflight.trust_state != :allowed -> + {:blocked, :trust_missing} + + preflight.risk_class == :destructive and + Map.get(context, :destructive_action_approved?) != true -> + {:blocked, :destructive_action_requires_approval} + + true -> + :allowed + end + end + + defp evaluate(%PreflightResult{kind: :ambiguous}, _context), do: {:blocked, :ambiguous_match} + defp evaluate(%PreflightResult{kind: :unknown}, _context), do: {:blocked, :unknown_plugin_or_command} + + defp evaluate(%PreflightResult{kind: :not_applicable}, _context), + do: {:blocked, :not_user_level_plugin_input} + + defp finalize({:blocked, reason}, task_request, preflight, context) do + {:ok, + %{ + type: :user_level_plugin_invocation, + status: :blocked, + task_request_id: to_string(task_request.id), + preflight: preflight, + argv: argv_for(preflight, context), + command: command_for(context), + blocked_reason: reason + }} + end + + defp finalize(:allowed, task_request, preflight, context) do + argv = argv_for(preflight, context) + command = command_for(context) + runner = Map.get(context, :external_command_runner) + + cond do + runner == nil -> + {:error, :external_command_runner_not_configured} + + not is_function(runner, 3) -> + {:error, :invalid_external_command_runner} + + true -> + case runner.(command, argv, runner_opts(context)) do + {:ok, execution} -> + {:ok, + %{ + type: :user_level_plugin_invocation, + status: :invoked, + task_request_id: to_string(task_request.id), + preflight: preflight, + argv: argv, + command: command, + execution: execution + }} + + {:error, reason} -> + {:ok, + %{ + type: :user_level_plugin_invocation, + status: :blocked, + task_request_id: to_string(task_request.id), + preflight: preflight, + argv: argv, + command: command, + blocked_reason: {:external_command_failed, reason} + }} + end + end + end + + defp argv_for(%PreflightResult{kind: :unique_match, plugin: plugin, command: command, args: args}, _context) do + [plugin.plugin_id, command.name | args] + end + + defp argv_for(_preflight, _context), do: [] + + defp command_for(context), do: Map.get(context, :command, @default_command) + + defp runner_opts(context) do + context + |> Map.take([:cwd, :env, :timeout_ms]) + |> Map.new() + end +end diff --git a/lib/ourocode/task_request.ex b/lib/ourocode/task_request.ex index 99a1641..1a20e72 100644 --- a/lib/ourocode/task_request.ex +++ b/lib/ourocode/task_request.ex @@ -22,15 +22,17 @@ defmodule Ourocode.TaskRequest do ] @type routing_decision :: %{ - required(:kind) => :runtime | :ouroboros_workflow | :mcp_flow, - required(:execution_route) => :runtime | :ouroboros_workflow | :mcp_flow, + required(:kind) => :runtime | :ouroboros_workflow | :mcp_flow | :user_level_plugin, + required(:execution_route) => + :runtime | :ouroboros_workflow | :mcp_flow | :user_level_plugin, required(:runtime_source) => :auto | :codex | :opencode | :claude_code | :ouroboros | :mcp, required(:transport) => :auto | :stdio | :streamable_http | :sse, required(:requires_command_syntax?) => false, required(:advanced_shortcut?) => boolean(), required(:reason) => atom(), - optional(:adapter_route) => atom() + optional(:adapter_route) => atom(), + optional(:plugin_id) => String.t() } @type t :: %__MODULE__{ diff --git a/test/ourocode/plugin/user_level/entry_test.exs b/test/ourocode/plugin/user_level/entry_test.exs new file mode 100644 index 0000000..20b5b11 --- /dev/null +++ b/test/ourocode/plugin/user_level/entry_test.exs @@ -0,0 +1,74 @@ +defmodule Ourocode.Plugin.UserLevel.EntryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Entry + alias Ourocode.TaskRequest + + defp original_routing_decision do + %{ + kind: :ouroboros_workflow, + execution_route: :ouroboros_workflow, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :explicit_ouroboros_shortcut, + adapter_route: :workflow + } + end + + defp task(input) do + %TaskRequest{ + id: "tr-#{System.unique_integer([:positive])}", + source: :cli, + task_input: input, + submitted_at_ms: 0, + routing_decision: original_routing_decision() + } + end + + defp superpowers do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: ["filesystem:read"], + commands: [%{name: "list"}] + }) + + capability + end + + test "rewrites routing_decision to :user_level_plugin when input targets a known plugin" do + refined = Entry.refine(task("ooo superpowers list"), [superpowers()]) + + assert refined.routing_decision == %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers" + } + end + + test "leaves routing_decision unchanged when input is not ooo-shaped" do + task_request = task("interview goal") + refined = Entry.refine(task_request, [superpowers()]) + assert refined.routing_decision == task_request.routing_decision + end + + test "leaves routing_decision unchanged when plugin is unknown" do + task_request = task("ooo unknown_plugin list") + refined = Entry.refine(task_request, [superpowers()]) + assert refined.routing_decision == task_request.routing_decision + end + + test "handles non-list capabilities by returning the task unchanged" do + task_request = task("ooo superpowers list") + assert ^task_request = Entry.refine(task_request, nil) + end +end diff --git a/test/ourocode/plugin/user_level/preflight_view_test.exs b/test/ourocode/plugin/user_level/preflight_view_test.exs new file mode 100644 index 0000000..4eb87b3 --- /dev/null +++ b/test/ourocode/plugin/user_level/preflight_view_test.exs @@ -0,0 +1,94 @@ +defmodule Ourocode.Plugin.UserLevel.PreflightViewTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Plugin.UserLevel.PreflightView + alias Ourocode.Plugin.UserLevel.Resolver + + defp superpowers do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + version: "0.4.2", + trust_scope: ["filesystem:read"], + commands: [ + %{ + name: "test-driven-development", + aliases: ["tdd"], + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_hint: "suggest_run" + }, + %{name: "list", risk_class: "read_only"} + ] + }) + + capability + end + + test "projects a unique_match into a JSON-safe map with trust + side effects" do + result = Resolver.resolve("ooo superpowers tdd --goal x", [superpowers()]) + + view = PreflightView.project(result) + + assert view.kind == :unique_match + assert view.plugin.plugin_id == "superpowers" + assert view.command.name == "test-driven-development" + assert view.args == ["--goal", "x"] + assert view.trust.state == :allowed + assert view.trust.remediation == nil + assert view.side_effects.execution == :pending_approval + assert view.side_effects.discovery == :read_only + assert view.side_effects.risk_class == :handoff_producing + + assert view.side_effects.expected_artifacts == [ + ".omx/superpowers/runs/*/seed.md" + ] + + assert view.side_effects.continuation_policy == :suggest + assert view.candidates == [] + assert view.match_explanation == %{matched_by: :alias, confidence: :alias} + end + + test "projects trust missing into remediation" do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: [], + commands: [%{name: "list", risk_class: "read_only"}] + }) + + result = Resolver.resolve("ooo superpowers list", [capability]) + view = PreflightView.project(result) + + assert view.trust.state == :missing + assert view.trust.remediation =~ "ouroboros plugin trust" + assert view.side_effects.execution == :pending_approval + end + + test "projects ambiguous candidates" do + a = superpowers() + {:ok, b} = Capability.new(%{plugin_id: "superpowers", source: :fixture}) + + result = Resolver.resolve("ooo superpowers list", [a, b]) + view = PreflightView.project(result) + + assert view.kind == :ambiguous + assert length(view.candidates) == 2 + assert view.side_effects.execution == :blocked + end + + test "projects not_applicable inputs with execution :blocked" do + result = %PreflightResult{ + kind: :not_applicable, + task_input: "hello" + } + + view = PreflightView.project(result) + assert view.kind == :not_applicable + assert view.side_effects.execution == :blocked + end +end diff --git a/test/ourocode/runtime/dispatcher/route_resolution_user_level_test.exs b/test/ourocode/runtime/dispatcher/route_resolution_user_level_test.exs new file mode 100644 index 0000000..ab8dd1e --- /dev/null +++ b/test/ourocode/runtime/dispatcher/route_resolution_user_level_test.exs @@ -0,0 +1,63 @@ +defmodule Ourocode.Runtime.Dispatcher.RouteResolutionUserLevelTest do + use ExUnit.Case, async: true + + alias Ourocode.Runtime.Dispatcher.RouteResolution + + describe ":user_level_plugin route validation" do + test "validate_decision/1 accepts a well-formed user_level_plugin decision" do + decision = %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers" + } + + assert :ok == RouteResolution.validate_decision(decision) + end + + test "validate_decision/1 rejects a user_level_plugin decision that carries an adapter_route" do + decision = %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers", + adapter_route: :workflow + } + + assert {:error, {:unexpected_adapter_route, :workflow}} = + RouteResolution.validate_decision(decision) + end + end + + describe ":user_level_plugin adapter_keys" do + test "scopes by plugin_id and falls back to generic key" do + decision = %{ + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + plugin_id: "superpowers" + } + + assert [{:user_level_plugin, "superpowers"}, :user_level_plugin] = + RouteResolution.adapter_keys(decision) + end + + test "falls back to :user_level_plugin when plugin_id is missing" do + decision = %{ + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto + } + + assert [:user_level_plugin] = RouteResolution.adapter_keys(decision) + end + end +end diff --git a/test/ourocode/runtime/user_level_plugin_invocation_test.exs b/test/ourocode/runtime/user_level_plugin_invocation_test.exs new file mode 100644 index 0000000..0a8e999 --- /dev/null +++ b/test/ourocode/runtime/user_level_plugin_invocation_test.exs @@ -0,0 +1,235 @@ +defmodule Ourocode.Runtime.UserLevelPluginInvocationTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Runtime.UserLevelPluginInvocation + alias Ourocode.TaskRequest + + @fixture_path Path.join([__DIR__, "..", "..", "fixtures", "user_level_plugins", "superpowers.json"]) + + defp superpowers_capability!(opts \\ []) do + json = File.read!(@fixture_path) + {:ok, [raw]} = OuroborosCLI.parse(json) + + raw = + raw + |> Map.put(:trust_scope, Keyword.get(opts, :trust_scope, raw.trust_scope)) + + {:ok, capability} = Capability.new(raw) + capability + end + + defp task_request(task_input) do + %TaskRequest{ + id: "tr-#{System.unique_integer([:positive])}", + source: :cli, + task_input: task_input, + submitted_at_ms: System.system_time(:millisecond), + routing_decision: %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers" + } + } + end + + describe "execute/2 — happy path" do + test "invokes via guarded runner and returns :invoked with execution result" do + runner = fn command, argv, _opts -> + send(self(), {:ran, command, argv}) + {:ok, %{status: 0, stdout: "superpowers list output", stderr: ""}} + end + + task = task_request("ooo superpowers list") + + assert {:ok, %{type: :user_level_plugin_invocation, status: :invoked} = result} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: runner + }) + + assert result.argv == ["superpowers", "list"] + assert result.command == "ouroboros" + assert result.execution.status == 0 + assert %PreflightResult{kind: :unique_match} = result.preflight + assert_received {:ran, "ouroboros", ["superpowers", "list"]} + end + + test "appends task args verbatim to argv" do + runner = fn _cmd, _argv, _opts -> {:ok, %{status: 0, stdout: "", stderr: ""}} end + + task = task_request("ooo superpowers tdd --goal \"add retry\"") + + assert {:ok, %{status: :invoked, argv: argv}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: runner + }) + + assert ["superpowers", "test-driven-development", "--goal" | _rest] = argv + end + end + + describe "execute/2 — blocked paths (no execution)" do + test "trust missing blocks dispatch with structured reason" do + runner = fn _cmd, _argv, _opts -> + flunk("runner must not be called when trust is missing") + end + + task = task_request("ooo superpowers list") + + assert {:ok, %{status: :blocked, blocked_reason: :trust_missing} = result} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!(trust_scope: [])], + external_command_runner: runner + }) + + assert result.preflight.trust_state == :missing + assert result.preflight.remediation =~ "ouroboros plugin trust" + end + + test "unknown command blocks with :unknown_plugin_or_command" do + runner = fn _cmd, _argv, _opts -> flunk("must not run") end + task = task_request("ooo superpowers nope") + + assert {:ok, %{status: :blocked, blocked_reason: :unknown_plugin_or_command}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: runner + }) + end + + test "destructive risk class blocks without explicit approval" do + runner = fn _cmd, _argv, _opts -> flunk("must not run") end + + {:ok, destructive_cap} = + Capability.new(%{ + plugin_id: "danger", + source: :fixture, + trust_scope: ["filesystem:write"], + commands: [%{name: "wipe", risk_class: "destructive"}] + }) + + task = %{task_request("ooo danger wipe") | routing_decision: %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "danger" + }} + + assert {:ok, %{status: :blocked, blocked_reason: :destructive_action_requires_approval}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [destructive_cap], + external_command_runner: runner + }) + end + + test "destructive risk class runs when explicit approval is granted" do + runner = fn _cmd, _argv, _opts -> {:ok, %{status: 0, stdout: "ok", stderr: ""}} end + + {:ok, destructive_cap} = + Capability.new(%{ + plugin_id: "danger", + source: :fixture, + trust_scope: ["filesystem:write"], + commands: [%{name: "wipe", risk_class: "destructive"}] + }) + + task = task_request("ooo danger wipe") + + assert {:ok, %{status: :invoked}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [destructive_cap], + external_command_runner: runner, + destructive_action_approved?: true + }) + end + end + + describe "execute/2 — runner contract" do + test "missing runner errors :external_command_runner_not_configured" do + task = task_request("ooo superpowers list") + + assert {:error, :external_command_runner_not_configured} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()] + }) + end + + test "invalid runner shape errors :invalid_external_command_runner" do + task = task_request("ooo superpowers list") + + assert {:error, :invalid_external_command_runner} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: :not_a_function + }) + end + + test "runner failure surfaces as blocked with structured reason" do + runner = fn _cmd, _argv, _opts -> + {:error, {:forbidden_external_command, :shell_wrapped_agent_command}} + end + + task = task_request("ooo superpowers list") + + assert {:ok, %{status: :blocked, blocked_reason: {:external_command_failed, _}}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: runner + }) + end + end + + describe "execute/2 — argv shell-injection guard handoff" do + test "argv is a list with no shell concatenation, even with injection-shaped args" do + runner = fn command, argv, _opts -> + send(self(), {:argv, command, argv}) + {:ok, %{status: 0, stdout: "", stderr: ""}} + end + + task = task_request(~s|ooo superpowers tdd --goal "; rm -rf /"|) + + assert {:ok, %{status: :invoked}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: runner + }) + + assert_received {:argv, "ouroboros", argv} + # Every arg is a separate list element. No element contains shell + # metacharacters interpolated into another arg. + assert is_list(argv) + assert Enum.all?(argv, &is_binary/1) + end + end + + describe "execute/2 — context validation" do + test "missing capabilities errors :capabilities_required_in_context" do + task = task_request("ooo superpowers list") + assert {:error, :capabilities_required_in_context} = + UserLevelPluginInvocation.execute(task, %{external_command_runner: fn _, _, _ -> {:ok, %{}} end}) + end + + test "non-Capability items in :capabilities errors :invalid_capabilities_in_context" do + task = task_request("ooo superpowers list") + + assert {:error, :invalid_capabilities_in_context} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [%{plugin_id: "x"}], + external_command_runner: fn _, _, _ -> {:ok, %{}} end + }) + end + end +end From d07181f9c91e449b0be015c7e9f72f20f05d8ed5 Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Mon, 25 May 2026 17:20:37 +0900 Subject: [PATCH 5/7] feat(plugin): add artifact capture, continuation policy, decision journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop from UserLevel plugin dispatch to verified follow-up: artifacts are discovered via the plugin's own declared globs, a conservative continuation policy decides whether to suggest or auto-run an `ooo run seed_path=...` step, and every decision phase is appended to the existing journal writer for audit and replay. What is added: - Ourocode.Plugin.UserLevel.ArtifactWatcher Pure scan over a CommandCapability's expected_artifacts globs rooted at the dispatch cwd. Classifies seed / handoff / report / log / other by basename. When lstat? is enabled (default), attaches size, sha256 digest (capped at 1 MiB), and generated_at. Never hardcodes plugin-internal storage paths. - Ourocode.Plugin.UserLevel.Continuation Pure policy: read_only → :none, handoff_producing without a seed → :none, handoff_producing with a seed → :suggest, and :auto_run only when the user's prompt contains an explicit opt-in phrase ("then run the generated handoff", "이어서 실행", etc.). Destructive commands never auto-run. - Ourocode.Plugin.UserLevel.DecisionJournal Appends one structured event per phase (:user_level_preflight, :user_level_dispatch, :user_level_artifact, :user_level_continuation) into the existing Ourocode.Journal.Writer. Accepts a Path.t or a 1-arity function so tests can capture events without filesystem writes. What is modified: - Ourocode.Runtime.UserLevelPluginInvocation After a successful runner call, scans declared artifacts, decides continuation, and attaches both to the result envelope as optional :artifacts and :continuation keys. When :decision_journal is provided in the dispatch context, every phase emits a structured event. Behaviour without these context keys is unchanged from PR 4 (backward-compatible). What is intentionally NOT in this PR: - Registry supervision wiring in ApplicationServices. The standalone registry is still usable today via Ourocode.Plugin.UserLevel.Registry callers (e.g. CLI integration); supervision wiring is a runtime integration concern that should be planned alongside TUI rendering and tracked in a follow-up. - TUI panel rendering. PR 4 ships the JSON-safe PreflightView; the actual TUI integration is downstream UI work. - /plugins refresh slash command. Follow-up. - Full failure recovery (#17 beyond structured trust-blocked errors) and durable session lifecycle (#24). Deferred per docs/userlevel-plugin-dispatch.md "Deferred" section. Tests: 4 ExUnit modules (async: true). ArtifactWatcher (5 tests with real tmp filesystem), Continuation (10 tests across risk classes + opt-in phrases), DecisionJournal (6 tests with capture function), UserLevelPluginInvocation post-execution integration (4 tests with end-to-end seed detection + auto_run intent + journal emission + blocked-still-emits). Closes #7 Closes #10 Closes #11 Closes #26 Closes #28 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugin/user_level/artifact_watcher.ex | 114 +++++++++++++ .../plugin/user_level/continuation.ex | 136 +++++++++++++++ .../plugin/user_level/decision_journal.ex | 132 +++++++++++++++ .../runtime/user_level_plugin_invocation.ex | 119 ++++++++++---- .../user_level/artifact_watcher_test.exs | 107 ++++++++++++ .../plugin/user_level/continuation_test.exs | 132 +++++++++++++++ .../user_level/decision_journal_test.exs | 113 +++++++++++++ ..._plugin_invocation_post_execution_test.exs | 155 ++++++++++++++++++ 8 files changed, 977 insertions(+), 31 deletions(-) create mode 100644 lib/ourocode/plugin/user_level/artifact_watcher.ex create mode 100644 lib/ourocode/plugin/user_level/continuation.ex create mode 100644 lib/ourocode/plugin/user_level/decision_journal.ex create mode 100644 test/ourocode/plugin/user_level/artifact_watcher_test.exs create mode 100644 test/ourocode/plugin/user_level/continuation_test.exs create mode 100644 test/ourocode/plugin/user_level/decision_journal_test.exs create mode 100644 test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs diff --git a/lib/ourocode/plugin/user_level/artifact_watcher.ex b/lib/ourocode/plugin/user_level/artifact_watcher.ex new file mode 100644 index 0000000..0ac951a --- /dev/null +++ b/lib/ourocode/plugin/user_level/artifact_watcher.ex @@ -0,0 +1,114 @@ +defmodule Ourocode.Plugin.UserLevel.ArtifactWatcher do + @moduledoc """ + Scans for artifact paths declared by a UserLevel plugin command after the + plugin run completes. + + Only the globs published by the plugin's own + `CommandCapability.expected_artifacts` list are considered. `ourocode` + never hardcodes plugin-internal storage paths. + + This module is pure and has no GenServer / process state. It is invoked + by `Ourocode.Runtime.UserLevelPluginInvocation` right after the + external command runner returns. + """ + + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @type artifact :: %{ + required(:kind) => :seed | :handoff | :report | :log | :other, + required(:path) => String.t(), + required(:glob) => String.t(), + optional(:size) => non_neg_integer(), + optional(:digest) => String.t(), + optional(:generated_at) => DateTime.t() + } + + @doc """ + Returns the matched artifact list for the given command capability and cwd. + + When `:lstat?` is `true` (default), each matched path gets size, digest, and + generated_at fields populated from the local file system. Pass `:lstat?: + false` to keep the scan filesystem-free (useful in tests where the file + doesn't need to exist). + """ + @spec scan(CommandCapability.t(), Path.t(), keyword()) :: [artifact()] + def scan(%CommandCapability{expected_artifacts: globs}, cwd, opts \\ []) + when is_binary(cwd) and is_list(globs) do + lstat? = Keyword.get(opts, :lstat?, true) + + globs + |> Enum.flat_map(fn glob -> expand_glob(glob, cwd, lstat?) end) + |> Enum.uniq_by(& &1.path) + end + + defp expand_glob(glob, cwd, lstat?) do + full_glob = Path.expand(glob, cwd) + + full_glob + |> Path.wildcard(match_dot: false) + |> Enum.map(fn path -> build_artifact(path, glob, lstat?) end) + end + + defp build_artifact(path, glob, lstat?) do + base = %{ + kind: classify(path), + path: path, + glob: glob + } + + if lstat? do + add_lstat(base, path) + else + base + end + end + + defp add_lstat(artifact, path) do + case File.stat(path, time: :posix) do + {:ok, %File.Stat{size: size, mtime: mtime}} -> + artifact + |> Map.put(:size, size) + |> Map.put(:generated_at, posix_to_datetime(mtime)) + |> maybe_digest(path) + + {:error, _reason} -> + artifact + end + end + + defp posix_to_datetime(seconds) when is_integer(seconds) do + DateTime.from_unix!(seconds) + end + + defp maybe_digest(artifact, path) do + # Only digest small text artifacts (Seed/handoff are markdown; size cap is + # to avoid hashing arbitrary plugin-emitted blobs). + case artifact do + %{size: size} when is_integer(size) and size <= 1_048_576 -> + case File.read(path) do + {:ok, content} -> + digest = :crypto.hash(:sha256, content) |> Base.encode16(case: :lower) + Map.put(artifact, :digest, "sha256:" <> digest) + + {:error, _reason} -> + artifact + end + + _other -> + artifact + end + end + + defp classify(path) do + basename = path |> Path.basename() |> String.downcase() + + cond do + basename == "seed.md" -> :seed + basename == "handoff.md" -> :handoff + basename in ["report.md", "evidence.json"] -> :report + String.ends_with?(basename, ".log") -> :log + String.ends_with?(basename, ".jsonl") and String.contains?(basename, "audit") -> :log + true -> :other + end + end +end diff --git a/lib/ourocode/plugin/user_level/continuation.ex b/lib/ourocode/plugin/user_level/continuation.ex new file mode 100644 index 0000000..472ace0 --- /dev/null +++ b/lib/ourocode/plugin/user_level/continuation.ex @@ -0,0 +1,136 @@ +defmodule Ourocode.Plugin.UserLevel.Continuation do + @moduledoc """ + Decides whether a UserLevel plugin run should be followed up with an + Ouroboros workflow step (`ooo run seed_path=...`), and whether to auto-run + that follow-up or merely suggest it. + + Policy is intentionally conservative: + + * `:read_only` commands → no continuation. + * `:handoff_producing` commands with a detected seed artifact → suggest + a continuation. Auto-run only when the original prompt contains an + explicit opt-in phrase such as "then run the generated handoff" (en) + or "이어서 실행" (ko). + * `:destructive` commands → never auto-continue. The continuation card + may be suggested but always requires explicit approval. + + Auto-run intent detection is a small allow-list of substrings. Free-form + natural-language detection is out of scope. + """ + + alias Ourocode.Plugin.UserLevel.ArtifactWatcher + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + alias Ourocode.Plugin.UserLevel.PreflightResult + + @type decision :: %{ + required(:action) => :none | :suggest | :auto_run, + required(:seed_path) => String.t() | nil, + required(:command_template) => String.t() | nil, + required(:reason) => atom() + } + + # Explicit opt-in phrases. Keep this list short and review-friendly. + @auto_run_intents [ + "then run the generated handoff", + "then run the seed", + "and run the seed", + "이어서 실행", + "이후 실행" + ] + + @doc """ + Decides the continuation action for a finished run. + + Inputs: + + * `preflight` — the original PreflightResult (provides risk class + + continuation policy + the user's task_input). + * `artifacts` — list of artifacts produced by the run (typically the + output of `ArtifactWatcher.scan/3`). + """ + @spec decide(PreflightResult.t(), [ArtifactWatcher.artifact()]) :: decision() + def decide(%PreflightResult{} = preflight, artifacts) when is_list(artifacts) do + seed = Enum.find(artifacts, &(&1.kind == :seed)) + + case continuation_action(preflight, seed) do + :auto_run -> + %{ + action: :auto_run, + seed_path: seed && seed.path, + command_template: command_template(seed), + reason: :auto_run_requested + } + + :suggest -> + %{ + action: :suggest, + seed_path: seed && seed.path, + command_template: command_template(seed), + reason: suggest_reason(preflight) + } + + :none -> + %{ + action: :none, + seed_path: nil, + command_template: nil, + reason: none_reason(preflight, seed) + } + end + end + + defp continuation_action(%PreflightResult{kind: :unique_match} = preflight, seed) do + cond do + preflight.risk_class == :read_only -> + :none + + preflight.risk_class == :destructive -> + if seed, do: :suggest, else: :none + + seed == nil -> + :none + + auto_run_requested?(preflight.task_input) and + allows_auto_run?(preflight.command) -> + :auto_run + + true -> + :suggest + end + end + + defp continuation_action(_preflight, _seed), do: :none + + defp allows_auto_run?(%CommandCapability{continuation_hint: hint}) do + hint in [:auto_run_when_requested, :suggest_run] + end + + defp allows_auto_run?(_command), do: false + + @doc """ + Returns `true` when the user's input contains an explicit opt-in phrase + for auto-running the generated continuation. Public so callers can preview + the intent without re-running the decision. + """ + @spec auto_run_requested?(String.t()) :: boolean() + def auto_run_requested?(task_input) when is_binary(task_input) do + normalized = String.downcase(task_input) + Enum.any?(@auto_run_intents, &String.contains?(normalized, &1)) + end + + def auto_run_requested?(_other), do: false + + defp command_template(nil), do: nil + defp command_template(%{path: path}), do: "ooo run seed_path=#{path}" + + defp suggest_reason(%PreflightResult{risk_class: :destructive}), + do: :destructive_requires_explicit_approval + + defp suggest_reason(_preflight), do: :user_confirmation_required + + defp none_reason(%PreflightResult{risk_class: :read_only}, _seed), do: :read_only_command + + defp none_reason(_preflight, nil), do: :no_continuation_artifact + + defp none_reason(_preflight, _seed), do: :no_continuation_policy +end diff --git a/lib/ourocode/plugin/user_level/decision_journal.ex b/lib/ourocode/plugin/user_level/decision_journal.ex new file mode 100644 index 0000000..933e7e4 --- /dev/null +++ b/lib/ourocode/plugin/user_level/decision_journal.ex @@ -0,0 +1,132 @@ +defmodule Ourocode.Plugin.UserLevel.DecisionJournal do + @moduledoc """ + Appends one structured event per UserLevel plugin decision phase into the + existing `Ourocode.Journal.Writer`. + + Four phases are recorded so the audit trail can answer "why did ourocode + pick this plugin, did it run, what did it produce, did the user continue": + + * `:user_level_preflight` — the PreflightResult that drove dispatch. + * `:user_level_dispatch` — the invocation envelope (argv, status, + blocked reason or execution status). + * `:user_level_artifact` — one event per produced artifact (so the + audit can reconstruct which files attached to which task). + * `:user_level_continuation` — the continuation decision (none / + suggest / auto_run, with seed path and reason). + + Events are written via `Journal.Writer.append/2` so they share the same + durable format and sequencing rules as the rest of the runtime journal. + The module is a thin shape-builder; it never decides what to log on its + own. + """ + + alias Ourocode.Journal.Writer + alias Ourocode.Plugin.UserLevel.ArtifactWatcher + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Plugin.UserLevel.PreflightView + + @type writer :: (map() -> :ok | {:error, term()}) + + @doc """ + Logs a preflight event. + + `journal` may be a `Path.t()` (passed straight to `Journal.Writer.append/2`) + or a 1-arity function for tests (`fn event -> :ok end`). + """ + @spec log_preflight(any(), String.t(), PreflightResult.t()) :: :ok | {:error, term()} + def log_preflight(journal, task_request_id, %PreflightResult{} = preflight) do + append(journal, base_event(:user_level_preflight, task_request_id, %{ + preflight: PreflightView.project(preflight) + })) + end + + @doc """ + Logs a dispatch envelope event. + """ + @spec log_dispatch(any(), String.t(), map()) :: :ok | {:error, term()} + def log_dispatch(journal, task_request_id, invocation) when is_map(invocation) do + append(journal, base_event(:user_level_dispatch, task_request_id, %{ + status: Map.get(invocation, :status), + command: Map.get(invocation, :command), + argv: Map.get(invocation, :argv), + blocked_reason: Map.get(invocation, :blocked_reason), + execution_status: get_in(invocation, [:execution, :status]), + plugin_id: get_in(invocation, [:preflight, Access.key(:plugin), Access.key(:plugin_id)]) + })) + end + + @doc """ + Logs one event per produced artifact. + + No-op when the artifact list is empty. + """ + @spec log_artifacts(any(), String.t(), [ArtifactWatcher.artifact()]) :: + :ok | {:error, term()} + def log_artifacts(_journal, _task_request_id, []), do: :ok + + def log_artifacts(journal, task_request_id, artifacts) when is_list(artifacts) do + Enum.reduce_while(artifacts, :ok, fn artifact, _acc -> + event = base_event(:user_level_artifact, task_request_id, %{ + artifact_kind: Map.get(artifact, :kind), + path: Map.get(artifact, :path), + glob: Map.get(artifact, :glob), + size: Map.get(artifact, :size), + digest: Map.get(artifact, :digest), + generated_at: maybe_iso(Map.get(artifact, :generated_at)) + }) + + case append(journal, event) do + :ok -> {:cont, :ok} + {:error, _reason} = err -> {:halt, err} + end + end) + end + + @doc """ + Logs the continuation decision (none / suggest / auto_run). + """ + @spec log_continuation(any(), String.t(), map()) :: :ok | {:error, term()} + def log_continuation(journal, task_request_id, decision) when is_map(decision) do + append(journal, base_event(:user_level_continuation, task_request_id, %{ + action: Map.get(decision, :action), + seed_path: Map.get(decision, :seed_path), + command_template: Map.get(decision, :command_template), + reason: Map.get(decision, :reason) + })) + end + + defp base_event(type, task_request_id, payload) do + %{ + "event_type" => Atom.to_string(type), + "task_request_id" => to_string(task_request_id), + "recorded_at_ms" => System.system_time(:millisecond), + "payload" => stringify(payload) + } + end + + defp append(journal, event) when is_function(journal, 1), do: journal.(event) + + defp append(journal, event) when is_binary(journal) do + Writer.append(journal, event) + end + + defp append(_journal, _event), do: {:error, :invalid_journal_target} + + defp maybe_iso(nil), do: nil + defp maybe_iso(%DateTime{} = dt), do: DateTime.to_iso8601(dt) + defp maybe_iso(other), do: other + + defp stringify(map) when is_map(map) do + Map.new(map, fn {k, v} -> {to_string(k), stringify_value(v)} end) + end + + defp stringify_value(value) when is_map(value), do: stringify(value) + defp stringify_value(value) when is_list(value), do: Enum.map(value, &stringify_value/1) + defp stringify_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt) + defp stringify_value(%Capability{} = cap), do: stringify(Map.from_struct(cap)) + defp stringify_value(value) when is_atom(value) and not is_boolean(value) and not is_nil(value), + do: Atom.to_string(value) + + defp stringify_value(value), do: value +end diff --git a/lib/ourocode/runtime/user_level_plugin_invocation.ex b/lib/ourocode/runtime/user_level_plugin_invocation.ex index 395b5d5..99ff6f2 100644 --- a/lib/ourocode/runtime/user_level_plugin_invocation.ex +++ b/lib/ourocode/runtime/user_level_plugin_invocation.ex @@ -52,7 +52,10 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocation do @behaviour Ourocode.Runtime.Adapter + alias Ourocode.Plugin.UserLevel.ArtifactWatcher alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Continuation + alias Ourocode.Plugin.UserLevel.DecisionJournal alias Ourocode.Plugin.UserLevel.PreflightResult alias Ourocode.Plugin.UserLevel.Resolver alias Ourocode.TaskRequest @@ -67,7 +70,9 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocation do required(:argv) => [String.t()], required(:command) => String.t(), optional(:execution) => map(), - optional(:blocked_reason) => atom() + optional(:blocked_reason) => atom(), + optional(:artifacts) => [ArtifactWatcher.artifact()], + optional(:continuation) => map() } @impl true @@ -122,16 +127,18 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocation do do: {:blocked, :not_user_level_plugin_input} defp finalize({:blocked, reason}, task_request, preflight, context) do - {:ok, - %{ - type: :user_level_plugin_invocation, - status: :blocked, - task_request_id: to_string(task_request.id), - preflight: preflight, - argv: argv_for(preflight, context), - command: command_for(context), - blocked_reason: reason - }} + envelope = %{ + type: :user_level_plugin_invocation, + status: :blocked, + task_request_id: to_string(task_request.id), + preflight: preflight, + argv: argv_for(preflight, context), + command: command_for(context), + blocked_reason: reason + } + + record_journal(:blocked, envelope, preflight, [], nil, context) + {:ok, envelope} end defp finalize(:allowed, task_request, preflight, context) do @@ -149,32 +156,82 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocation do true -> case runner.(command, argv, runner_opts(context)) do {:ok, execution} -> - {:ok, - %{ - type: :user_level_plugin_invocation, - status: :invoked, - task_request_id: to_string(task_request.id), - preflight: preflight, - argv: argv, - command: command, - execution: execution - }} + envelope = %{ + type: :user_level_plugin_invocation, + status: :invoked, + task_request_id: to_string(task_request.id), + preflight: preflight, + argv: argv, + command: command, + execution: execution + } + + {artifacts, continuation} = post_execution(preflight, context) + + envelope = + envelope + |> maybe_put(:artifacts, artifacts) + |> maybe_put(:continuation, continuation) + + record_journal(:invoked, envelope, preflight, artifacts, continuation, context) + {:ok, envelope} {:error, reason} -> - {:ok, - %{ - type: :user_level_plugin_invocation, - status: :blocked, - task_request_id: to_string(task_request.id), - preflight: preflight, - argv: argv, - command: command, - blocked_reason: {:external_command_failed, reason} - }} + envelope = %{ + type: :user_level_plugin_invocation, + status: :blocked, + task_request_id: to_string(task_request.id), + preflight: preflight, + argv: argv, + command: command, + blocked_reason: {:external_command_failed, reason} + } + + record_journal(:blocked, envelope, preflight, [], nil, context) + {:ok, envelope} end end end + defp post_execution(%PreflightResult{kind: :unique_match, command: command} = preflight, context) + when not is_nil(command) do + cwd = Map.get(context, :cwd) || File.cwd!() + + artifacts = + if command.expected_artifacts == [] do + [] + else + ArtifactWatcher.scan(command, cwd, + lstat?: Map.get(context, :artifact_lstat?, true) + ) + end + + continuation = Continuation.decide(preflight, artifacts) + {artifacts, continuation} + end + + defp post_execution(_preflight, _context), do: {[], nil} + + defp maybe_put(envelope, _key, []), do: envelope + defp maybe_put(envelope, _key, nil), do: envelope + defp maybe_put(envelope, key, value), do: Map.put(envelope, key, value) + + defp record_journal(_phase, envelope, preflight, artifacts, continuation, context) do + case Map.get(context, :decision_journal) do + nil -> + :ok + + target -> + task_id = Map.get(envelope, :task_request_id, "") + _ = DecisionJournal.log_preflight(target, task_id, preflight) + _ = DecisionJournal.log_dispatch(target, task_id, envelope) + _ = DecisionJournal.log_artifacts(target, task_id, artifacts) + + if continuation, do: _ = DecisionJournal.log_continuation(target, task_id, continuation) + :ok + end + end + defp argv_for(%PreflightResult{kind: :unique_match, plugin: plugin, command: command, args: args}, _context) do [plugin.plugin_id, command.name | args] end diff --git a/test/ourocode/plugin/user_level/artifact_watcher_test.exs b/test/ourocode/plugin/user_level/artifact_watcher_test.exs new file mode 100644 index 0000000..9f1c3d3 --- /dev/null +++ b/test/ourocode/plugin/user_level/artifact_watcher_test.exs @@ -0,0 +1,107 @@ +defmodule Ourocode.Plugin.UserLevel.ArtifactWatcherTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.ArtifactWatcher + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + setup do + tmp = Path.join(System.tmp_dir!(), "ourocode_artifact_watcher_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + %{cwd: tmp} + end + + defp write!(path, content) do + path |> Path.dirname() |> File.mkdir_p!() + File.write!(path, content) + end + + test "matches a seed.md under the declared glob", %{cwd: cwd} do + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"] + }) + + seed = Path.join([cwd, ".omx", "superpowers", "runs", "abc123", "seed.md"]) + write!(seed, "# seed\n") + + [artifact] = ArtifactWatcher.scan(command, cwd) + + assert artifact.kind == :seed + assert artifact.path == seed + assert artifact.glob == ".omx/superpowers/runs/*/seed.md" + assert artifact.size > 0 + assert "sha256:" <> _ = artifact.digest + assert %DateTime{} = artifact.generated_at + end + + test "classifies handoff and report files", %{cwd: cwd} do + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + risk_class: "handoff_producing", + expected_artifacts: [".omx/runs/*/*"] + }) + + handoff = Path.join([cwd, ".omx", "runs", "x", "handoff.md"]) + report = Path.join([cwd, ".omx", "runs", "x", "report.md"]) + log = Path.join([cwd, ".omx", "runs", "x", "audit.jsonl"]) + other = Path.join([cwd, ".omx", "runs", "x", "extra.bin"]) + + Enum.each([handoff, report, log, other], &write!(&1, "data")) + + artifacts = ArtifactWatcher.scan(command, cwd) + by_path = Map.new(artifacts, &{&1.path, &1.kind}) + + assert by_path[handoff] == :handoff + assert by_path[report] == :report + assert by_path[log] == :log + assert by_path[other] == :other + end + + test "deduplicates artifacts that match multiple globs", %{cwd: cwd} do + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + risk_class: "handoff_producing", + expected_artifacts: [ + ".omx/runs/*/seed.md", + ".omx/runs/**/*.md" + ] + }) + + seed = Path.join([cwd, ".omx", "runs", "x", "seed.md"]) + write!(seed, "# seed\n") + + artifacts = ArtifactWatcher.scan(command, cwd) + assert length(artifacts) == 1 + end + + test "returns empty list when nothing matches", %{cwd: cwd} do + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + risk_class: "handoff_producing", + expected_artifacts: [".omx/nothing/*.md"] + }) + + assert ArtifactWatcher.scan(command, cwd) == [] + end + + test "lstat?: false skips file metadata", %{cwd: cwd} do + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + expected_artifacts: [".omx/x/*"] + }) + + write!(Path.join([cwd, ".omx", "x", "seed.md"]), "x") + + [artifact] = ArtifactWatcher.scan(command, cwd, lstat?: false) + refute Map.has_key?(artifact, :size) + refute Map.has_key?(artifact, :digest) + refute Map.has_key?(artifact, :generated_at) + end +end diff --git a/test/ourocode/plugin/user_level/continuation_test.exs b/test/ourocode/plugin/user_level/continuation_test.exs new file mode 100644 index 0000000..bcaa617 --- /dev/null +++ b/test/ourocode/plugin/user_level/continuation_test.exs @@ -0,0 +1,132 @@ +defmodule Ourocode.Plugin.UserLevel.ContinuationTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + alias Ourocode.Plugin.UserLevel.Continuation + alias Ourocode.Plugin.UserLevel.PreflightResult + + defp preflight(opts \\ []) do + risk = Keyword.get(opts, :risk_class, :handoff_producing) + hint = Keyword.get(opts, :continuation_hint, :suggest_run) + + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + risk_class: Atom.to_string(risk), + continuation_hint: Atom.to_string(hint), + expected_artifacts: [".omx/runs/*/seed.md"] + }) + + %PreflightResult{ + kind: :unique_match, + task_input: Keyword.get(opts, :task_input, "ooo superpowers tdd --goal x"), + command: command, + risk_class: risk, + expected_artifacts: command.expected_artifacts, + continuation_policy: :suggest + } + end + + defp seed_artifact(path \\ "/tmp/runs/x/seed.md") do + %{kind: :seed, path: path, glob: ".omx/runs/*/seed.md"} + end + + describe "decide/2 — auto-run intent" do + test "auto-runs when prompt asks 'then run the generated handoff'" do + result = + Continuation.decide( + preflight(task_input: "ooo superpowers tdd --goal x then run the generated handoff"), + [seed_artifact()] + ) + + assert result.action == :auto_run + assert result.seed_path == "/tmp/runs/x/seed.md" + assert result.command_template == "ooo run seed_path=/tmp/runs/x/seed.md" + assert result.reason == :auto_run_requested + end + + test "auto-runs for Korean opt-in phrase" do + result = + Continuation.decide( + preflight(task_input: "ooo superpowers tdd --goal x 이어서 실행"), + [seed_artifact()] + ) + + assert result.action == :auto_run + end + + test "does not auto-run without an explicit opt-in phrase" do + result = + Continuation.decide(preflight(), [seed_artifact()]) + + assert result.action == :suggest + assert result.command_template == "ooo run seed_path=/tmp/runs/x/seed.md" + assert result.reason == :user_confirmation_required + end + end + + describe "decide/2 — risk class gating" do + test "read_only commands never continue" do + result = + Continuation.decide( + preflight( + risk_class: :read_only, + task_input: "ooo superpowers list then run the generated handoff" + ), + [seed_artifact()] + ) + + assert result.action == :none + assert result.reason == :read_only_command + end + + test "destructive commands never auto-run, even when opt-in is present" do + result = + Continuation.decide( + preflight( + risk_class: :destructive, + task_input: "ooo danger wipe then run the generated handoff" + ), + [seed_artifact()] + ) + + assert result.action == :suggest + assert result.reason == :destructive_requires_explicit_approval + end + end + + describe "decide/2 — no continuation artifact" do + test "no seed artifact returns :none with :no_continuation_artifact reason" do + result = Continuation.decide(preflight(), []) + assert result.action == :none + assert result.reason == :no_continuation_artifact + end + + test "only handoff (no seed) still returns :none" do + handoff = %{kind: :handoff, path: "/tmp/handoff.md", glob: ".omx/runs/*/handoff.md"} + result = Continuation.decide(preflight(), [handoff]) + assert result.action == :none + end + end + + describe "decide/2 — non-unique_match" do + test "ambiguous preflight returns :none" do + result = Continuation.decide(%PreflightResult{kind: :ambiguous}, [seed_artifact()]) + assert result.action == :none + end + end + + describe "auto_run_requested?/1" do + test "true for English opt-in" do + assert Continuation.auto_run_requested?("anything then run the seed please") + end + + test "true for Korean opt-in" do + assert Continuation.auto_run_requested?("실험 후 이후 실행") + end + + test "false for unrelated text" do + refute Continuation.auto_run_requested?("just run the plugin") + end + end +end diff --git a/test/ourocode/plugin/user_level/decision_journal_test.exs b/test/ourocode/plugin/user_level/decision_journal_test.exs new file mode 100644 index 0000000..9284a65 --- /dev/null +++ b/test/ourocode/plugin/user_level/decision_journal_test.exs @@ -0,0 +1,113 @@ +defmodule Ourocode.Plugin.UserLevel.DecisionJournalTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.DecisionJournal + alias Ourocode.Plugin.UserLevel.PreflightResult + + defp collect_events do + pid = self() + {pid, fn event -> send(pid, {:journal_event, event}); :ok end} + end + + defp preflight do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: ["filesystem:read"], + commands: [%{name: "list"}] + }) + + %PreflightResult{ + kind: :unique_match, + task_input: "ooo superpowers list", + plugin: capability, + command: hd(capability.commands), + args: [], + trust_state: :allowed, + risk_class: :read_only + } + end + + test "log_preflight/3 emits a user_level_preflight event with projected payload" do + {_pid, writer} = collect_events() + + assert :ok = DecisionJournal.log_preflight(writer, "task-1", preflight()) + + assert_received {:journal_event, event} + assert event["event_type"] == "user_level_preflight" + assert event["task_request_id"] == "task-1" + assert is_integer(event["recorded_at_ms"]) + assert %{"preflight" => %{"kind" => "unique_match"}} = event["payload"] + end + + test "log_dispatch/3 includes status, command, argv, plugin_id" do + {_pid, writer} = collect_events() + + envelope = %{ + status: :invoked, + command: "ouroboros", + argv: ["superpowers", "list"], + execution: %{status: 0}, + preflight: preflight() + } + + assert :ok = DecisionJournal.log_dispatch(writer, "task-1", envelope) + + assert_received {:journal_event, event} + assert event["event_type"] == "user_level_dispatch" + assert event["payload"]["status"] == "invoked" + assert event["payload"]["command"] == "ouroboros" + assert event["payload"]["argv"] == ["superpowers", "list"] + assert event["payload"]["execution_status"] == 0 + end + + test "log_artifacts/3 emits one event per artifact" do + {_pid, writer} = collect_events() + + artifacts = [ + %{kind: :seed, path: "/tmp/seed.md", glob: ".omx/*/seed.md", size: 12}, + %{kind: :handoff, path: "/tmp/handoff.md", glob: ".omx/*/handoff.md"} + ] + + assert :ok = DecisionJournal.log_artifacts(writer, "task-1", artifacts) + + assert_received {:journal_event, %{"event_type" => "user_level_artifact"} = first} + assert_received {:journal_event, %{"event_type" => "user_level_artifact"} = second} + refute_received {:journal_event, _} + + paths = Enum.map([first, second], & &1["payload"]["path"]) |> Enum.sort() + assert paths == ["/tmp/handoff.md", "/tmp/seed.md"] + end + + test "log_artifacts/3 with empty list is a no-op" do + {_pid, writer} = collect_events() + assert :ok = DecisionJournal.log_artifacts(writer, "task-1", []) + refute_received {:journal_event, _} + end + + test "log_continuation/3 captures action + seed_path + reason" do + {_pid, writer} = collect_events() + + decision = %{ + action: :suggest, + seed_path: "/tmp/seed.md", + command_template: "ooo run seed_path=/tmp/seed.md", + reason: :user_confirmation_required + } + + assert :ok = DecisionJournal.log_continuation(writer, "task-1", decision) + + assert_received {:journal_event, event} + assert event["event_type"] == "user_level_continuation" + assert event["payload"]["action"] == "suggest" + assert event["payload"]["seed_path"] == "/tmp/seed.md" + assert event["payload"]["reason"] == "user_confirmation_required" + end + + test "invalid journal target returns structured error" do + assert {:error, :invalid_journal_target} = + DecisionJournal.log_preflight(:not_callable, "task", preflight()) + end +end diff --git a/test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs b/test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs new file mode 100644 index 0000000..fa2bf87 --- /dev/null +++ b/test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs @@ -0,0 +1,155 @@ +defmodule Ourocode.Runtime.UserLevelPluginInvocationPostExecutionTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Runtime.UserLevelPluginInvocation + alias Ourocode.TaskRequest + + setup do + tmp = Path.join(System.tmp_dir!(), "ourocode_invocation_post_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + %{cwd: tmp} + end + + defp superpowers_capability do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: ["filesystem:read", "filesystem:write"], + commands: [ + %{ + name: "tdd", + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_hint: "suggest_run" + } + ] + }) + + capability + end + + defp task(input) do + %TaskRequest{ + id: "tr-#{System.unique_integer([:positive])}", + source: :cli, + task_input: input, + submitted_at_ms: 0, + routing_decision: %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers" + } + } + end + + defp write_seed(cwd) do + seed = Path.join([cwd, ".omx", "superpowers", "runs", "abc", "seed.md"]) + seed |> Path.dirname() |> File.mkdir_p!() + File.write!(seed, "# seed\n") + seed + end + + test "attaches discovered artifacts and a :suggest continuation to the envelope", + %{cwd: cwd} do + seed = write_seed(cwd) + + runner = fn _cmd, _argv, _opts -> + {:ok, %{status: 0, stdout: "", stderr: ""}} + end + + assert {:ok, envelope} = + UserLevelPluginInvocation.execute(task("ooo superpowers tdd --goal x"), %{ + capabilities: [superpowers_capability()], + external_command_runner: runner, + cwd: cwd + }) + + assert envelope.status == :invoked + + assert [%{kind: :seed, path: ^seed}] = envelope.artifacts + assert envelope.continuation.action == :suggest + assert envelope.continuation.seed_path == seed + + assert envelope.continuation.command_template == + "ooo run seed_path=#{seed}" + end + + test "auto_run continuation when prompt opts in explicitly", %{cwd: cwd} do + write_seed(cwd) + runner = fn _cmd, _argv, _opts -> {:ok, %{status: 0, stdout: "", stderr: ""}} end + + {:ok, envelope} = + UserLevelPluginInvocation.execute( + task("ooo superpowers tdd --goal x then run the generated handoff"), + %{ + capabilities: [superpowers_capability()], + external_command_runner: runner, + cwd: cwd + } + ) + + assert envelope.continuation.action == :auto_run + end + + test "decision journal callback receives preflight + dispatch + artifact + continuation events", + %{cwd: cwd} do + write_seed(cwd) + runner = fn _cmd, _argv, _opts -> {:ok, %{status: 0, stdout: "", stderr: ""}} end + + pid = self() + journal = fn event -> send(pid, {:journal, event["event_type"]}); :ok end + + {:ok, _envelope} = + UserLevelPluginInvocation.execute(task("ooo superpowers tdd --goal x"), %{ + capabilities: [superpowers_capability()], + external_command_runner: runner, + cwd: cwd, + decision_journal: journal + }) + + assert_received {:journal, "user_level_preflight"} + assert_received {:journal, "user_level_dispatch"} + assert_received {:journal, "user_level_artifact"} + assert_received {:journal, "user_level_continuation"} + end + + test "blocked dispatch still records preflight and dispatch journal events", + %{cwd: cwd} do + runner = fn _cmd, _argv, _opts -> flunk("must not run when trust missing") end + + {:ok, untrusted} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: [], + commands: [%{name: "tdd", risk_class: "handoff_producing"}] + }) + + pid = self() + journal = fn event -> send(pid, {:journal, event["event_type"]}); :ok end + + {:ok, envelope} = + UserLevelPluginInvocation.execute(task("ooo superpowers tdd --goal x"), %{ + capabilities: [untrusted], + external_command_runner: runner, + cwd: cwd, + decision_journal: journal + }) + + assert envelope.status == :blocked + assert envelope.blocked_reason == :trust_missing + + assert_received {:journal, "user_level_preflight"} + assert_received {:journal, "user_level_dispatch"} + refute_received {:journal, "user_level_artifact"} + refute_received {:journal, "user_level_continuation"} + end +end From 3652853d78d5a5ad11416a1840a50abc721868f8 Mon Sep 17 00:00:00 2001 From: shaun0927 Date: Fri, 29 May 2026 16:25:21 +0900 Subject: [PATCH 6/7] fix(install): default project dir to CWD and ensure Erlang runtime The release path documented in the README ("curl ... install.sh | bash" then "ourocode") failed on any machine other than the maintainer's: 1. StartupArgs hardcoded the default project directory to a build-time developer path (/Users/jaegyu.lee/Project/ourocode). Running `ourocode` without --project-dir on any other machine errored with "project directory does not exist". Default to the current working directory instead, with an OUROCODE_PROJECT_DIR override. 2. The bundled `ourocode` is an Erlang escript and needs the Erlang/OTP runtime (escript/erl) on PATH, but install.sh never checked for or installed it. On a fresh machine the installer "succeeded" yet every invocation died with "env: escript: No such file or directory". Add a best-effort runtime ensure step (brew on macOS, apt/dnf on Linux) that fails fast with manual instructions when it cannot help, with an OUROCODE_SKIP_ERLANG=1 escape hatch. Also document the Erlang/OTP requirement and the CWD default in the README. Verified with `mix test test/ourocode/cli_test.exs` (46 tests, 0 failures) and `bash -n install.sh`. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 15 ++++++++- install.sh | 53 ++++++++++++++++++++++++++++++++ lib/ourocode/cli/startup_args.ex | 26 ++++++++++++++-- test/ourocode/cli_test.exs | 18 ++++++++--- 4 files changed, 104 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b6d1692..6f88ae3 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,19 @@ Then run: ourocode ``` +With no arguments, `ourocode` uses the current working directory as the project +directory. Pass `--project-dir PATH` (or set `OUROCODE_PROJECT_DIR`) to point it +elsewhere. + +### Requirements + +The bundled `ourocode` is an Erlang escript, so it needs the **Erlang/OTP +runtime** (`escript`/`erl`) on your `PATH`. The installer installs it +best-effort (Homebrew on macOS, `apt`/`dnf` on Linux); if that is not possible +it stops with manual instructions. Install it yourself with `brew install +erlang`, `sudo apt-get install erlang`, or `sudo dnf install erlang`. Set +`OUROCODE_SKIP_ERLANG=1` to bypass the check. + Optional model backends: - Claude CLI @@ -96,7 +109,7 @@ curl -fsSL https://raw.githubusercontent.com/Q00/ourocode/release/bootstrap/inst ourocode ``` -`install.sh` downloads the matching GitHub Release tarball, installs `ourocode` into `~/.local/ourocode/`, and writes a launcher at `~/.local/bin/ourocode`. The launcher sets `OUROCODE_TTY` so the installed escript can find the bundled native tty helper. +`install.sh` downloads the matching GitHub Release tarball, installs `ourocode` into `~/.local/ourocode/`, and writes a launcher at `~/.local/bin/ourocode`. The launcher sets `OUROCODE_TTY` so the installed escript can find the bundled native tty helper. It also ensures the Erlang/OTP runtime is available (see [Requirements](#requirements)), since the escript cannot run without it. When run from a source checkout, the same installer uses bundled release binaries if present, or builds from source when needed. Set `OUROCODE_BUILD_FROM_SOURCE=1` to force a local build. diff --git a/install.sh b/install.sh index 237e856..86fb49d 100755 --- a/install.sh +++ b/install.sh @@ -75,6 +75,54 @@ download_release() { fi } +ensure_erlang_runtime() { + # The bundled `ourocode` is an Erlang escript and needs the Erlang/OTP runtime + # (`escript`/`erl`) on PATH to run. Release tarballs do not bundle the runtime, + # so a fresh machine ends up with a working launcher that cannot start. Make + # the runtime present here (best effort, like the Ouroboros step) so the + # documented `ourocode` quick start works after install. + if command -v escript >/dev/null 2>&1; then + return 0 + fi + + if [ "${OUROCODE_SKIP_ERLANG:-0}" = "1" ]; then + echo "==> skipping Erlang runtime check (OUROCODE_SKIP_ERLANG=1)" >&2 + return 0 + fi + + echo "==> Erlang runtime (escript) not found; ourocode needs it to run" + + local os + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + + if command -v brew >/dev/null 2>&1; then + echo " installing Erlang via Homebrew (best effort)" + if brew install erlang; then + brew link --overwrite erlang >/dev/null 2>&1 || true + fi + elif [ "$os" = "linux" ] && command -v apt-get >/dev/null 2>&1; then + echo " installing Erlang via apt-get (best effort; may require sudo)" + sudo apt-get update -y >/dev/null 2>&1 || true + sudo apt-get install -y erlang >/dev/null 2>&1 || true + elif [ "$os" = "linux" ] && command -v dnf >/dev/null 2>&1; then + echo " installing Erlang via dnf (best effort; may require sudo)" + sudo dnf install -y erlang >/dev/null 2>&1 || true + fi + + if ! command -v escript >/dev/null 2>&1; then + echo "" >&2 + echo "error: Erlang/OTP runtime not found and could not be installed automatically." >&2 + echo " ourocode is an Erlang escript and needs 'escript'/'erl' on PATH." >&2 + echo " Install Erlang, then re-run this installer:" >&2 + echo " macOS: brew install erlang" >&2 + echo " Debian/Ubuntu: sudo apt-get install erlang" >&2 + echo " Fedora: sudo dnf install erlang" >&2 + echo " Other platforms: https://www.erlang.org/downloads" >&2 + echo " (set OUROCODE_SKIP_ERLANG=1 to bypass this check)" >&2 + exit 1 + fi +} + echo "==> ourocode install" need_build=0 @@ -131,6 +179,11 @@ exec "$INSTALL_DIR/ourocode" "\$@" EOF chmod +x "$BIN_DIR/ourocode" +# The launcher above runs an Erlang escript; make sure the runtime exists before +# we try to invoke it (e.g. the `--detect` call below) or hand control back to +# the user. +ensure_erlang_runtime + # Ourocode surfaces the Ouroboros capability graph. This step is best-effort # and can be skipped for lean installs or CI with OUROCODE_SKIP_OUROBOROS=1. OUROBOROS_INSTALL_URL="${OUROBOROS_INSTALL_URL:-https://raw.githubusercontent.com/Q00/ouroboros/main/scripts/install.sh}" diff --git a/lib/ourocode/cli/startup_args.ex b/lib/ourocode/cli/startup_args.ex index 518ce01..b873e62 100644 --- a/lib/ourocode/cli/startup_args.ex +++ b/lib/ourocode/cli/startup_args.ex @@ -7,7 +7,7 @@ defmodule Ourocode.CLI.StartupArgs do config/task parsers after startup-only flags are removed. """ - @default_project_dir "/Users/jaegyu.lee/Project/ourocode" + @project_dir_env "OUROCODE_PROJECT_DIR" @project_dir_flags MapSet.new(["--project-dir", "--project", "-d"]) @smoke_test_flags MapSet.new(["--smoke-test", "--smoke", "--verify"]) @prompt_flags MapSet.new(["--prompt", "-p"]) @@ -87,10 +87,30 @@ defmodule Ourocode.CLI.StartupArgs do def parse(_args, _options), do: {:error, "CLI args must be a list"} @doc """ - Returns the default implementation project directory for the launcher. + Returns the default project directory for the launcher. + + Resolution order: + + * the `OUROCODE_PROJECT_DIR` environment variable when it is set to a + non-empty value (expanded to an absolute path), otherwise + * the current working directory. + + This keeps the documented `ourocode` (no `--project-dir`) quick-start working + on any machine instead of pointing at a build-time developer path. """ @spec default_project_dir() :: String.t() - def default_project_dir, do: @default_project_dir + def default_project_dir do + case System.get_env(@project_dir_env) do + value when is_binary(value) -> + case String.trim(value) do + "" -> File.cwd!() + trimmed -> Path.expand(trimmed) + end + + _ -> + File.cwd!() + end + end defp extract_startup_args(args, project_dir), do: extract_startup_args(args, project_dir, false, false, :text, [], []) diff --git a/test/ourocode/cli_test.exs b/test/ourocode/cli_test.exs index ebcfd4a..754b480 100644 --- a/test/ourocode/cli_test.exs +++ b/test/ourocode/cli_test.exs @@ -74,9 +74,19 @@ defmodule Ourocode.CLITest do import ExUnit.CaptureIO - test "startup resolves the fixed implementation project directory" do - assert Ourocode.CLI.resolve_project_dir() == - {:ok, "/Users/jaegyu.lee/Project/ourocode"} + test "startup resolves the current working directory by default" do + assert Ourocode.CLI.resolve_project_dir() == {:ok, File.cwd!()} + end + + test "startup honors the OUROCODE_PROJECT_DIR override" do + System.put_env("OUROCODE_PROJECT_DIR", File.cwd!()) + + try do + assert Ourocode.CLI.resolve_project_dir(StartupArgs.default_project_dir()) == + {:ok, File.cwd!()} + after + System.delete_env("OUROCODE_PROJECT_DIR") + end end test "startup argument parser separates launch args, config overrides, and task text" do @@ -269,7 +279,7 @@ defmodule Ourocode.CLITest do Ourocode.CLI.main([], Ourocode.CLITest.DashboardSpy) assert_receive {:dashboard_init, ^context} - assert context.project_dir == "/Users/jaegyu.lee/Project/ourocode" + assert context.project_dir == File.cwd!() assert is_binary(context.cwd) assert context.initial_task_request == nil assert context.plugin_config.plugins |> Enum.map(& &1.id) == ["ouroboros-plugin"] From 1ab384b2957355cda762cf4d6ec0dd6c4c785c2b Mon Sep 17 00:00:00 2001 From: Q00 Date: Thu, 4 Jun 2026 00:11:37 +0900 Subject: [PATCH 7/7] feat(plugin): wire UserLevel dispatch into runtime Connect the UserLevel plugin capability stack to the live terminal/runtime path. Prompt normalization now refines ooo plugin inputs from discovered capabilities, loop bindings dispatch user_level_plugin routes through the guarded command runner, and the runtime supervises a lazy UserLevel registry so startup stays offline-safe. This turns the previously isolated resolver/adapter modules into an executable path while keeping built-in Ouroboros workflow prompts on their existing MCP route. Affected files: - lib/ourocode/plugin/user_level/entry.ex - lib/ourocode/plugin/user_level/registry.ex - lib/ourocode/runtime/application_services.ex - lib/ourocode/runtime/application_state.ex - lib/ourocode/runtime/loop_binding_workflow_dispatch.ex - lib/ourocode/runtime/loop_bindings.ex - lib/ourocode/runtime/user_level_plugin_invocation.ex - lib/ourocode/terminal/event_loop_prompt_input.ex - lib/ourocode/terminal/event_loop_task_submission.ex - test/ourocode/cli_test.exs - test/ourocode/runtime/application_test.exs - test/ourocode/runtime/loop_binding_workflow_dispatch_test.exs - test/ourocode/terminal/event_loop_prompt_input_test.exs - test/ourocode/terminal/event_loop_task_submission_test.exs --- lib/ourocode/plugin/user_level/entry.ex | 49 +++++++++++ lib/ourocode/plugin/user_level/registry.ex | 4 +- lib/ourocode/runtime/application_services.ex | 2 + lib/ourocode/runtime/application_state.ex | 1 + .../runtime/loop_binding_workflow_dispatch.ex | 65 +++++++++++++- lib/ourocode/runtime/loop_bindings.ex | 27 ++++++ .../runtime/user_level_plugin_invocation.ex | 20 +++-- .../terminal/event_loop_prompt_input.ex | 30 +++++-- .../terminal/event_loop_task_submission.ex | 53 +++++++++++- test/ourocode/cli_test.exs | 3 +- test/ourocode/runtime/application_test.exs | 1 + .../loop_binding_workflow_dispatch_test.exs | 84 +++++++++++++++++++ .../terminal/event_loop_prompt_input_test.exs | 47 +++++++++++ .../event_loop_task_submission_test.exs | 51 +++++++++++ 14 files changed, 416 insertions(+), 21 deletions(-) diff --git a/lib/ourocode/plugin/user_level/entry.ex b/lib/ourocode/plugin/user_level/entry.ex index ced9d55..501f80c 100644 --- a/lib/ourocode/plugin/user_level/entry.ex +++ b/lib/ourocode/plugin/user_level/entry.ex @@ -24,6 +24,31 @@ defmodule Ourocode.Plugin.UserLevel.Entry do alias Ourocode.Plugin.UserLevel.Resolver alias Ourocode.TaskRequest + @known_ouroboros_commands MapSet.new([ + "auto", + "interview", + "pm", + "seed", + "run", + "execute", + "evolve", + "ralph", + "status", + "evaluate", + "qa", + "lateral", + "brownfield", + "cancel", + "resume_session", + "resume-session", + "update", + "setup", + "publish", + "welcome", + "tutorial", + "help" + ]) + @doc """ Returns a TaskRequest whose routing_decision is rewritten to `:user_level_plugin` when the input targets a known plugin; otherwise @@ -62,6 +87,30 @@ defmodule Ourocode.Plugin.UserLevel.Entry do def refine(task_request, _capabilities), do: task_request + @doc """ + Returns true when the prompt is shaped like an `ooo ...` command + that may need UserLevel plugin discovery. + + Built-in Ouroboros actions return false so normal interview/auto/run prompts + do not pay a plugin discovery cost. + """ + @spec candidate_input?(String.t()) :: boolean() + def candidate_input?(input) when is_binary(input) do + input + |> String.trim() + |> String.split(~r/\s+/u, trim: true) + |> case do + [prefix, plugin_token | _rest] -> + String.downcase(prefix) in ["ooo", "ouroboros"] and + not MapSet.member?(@known_ouroboros_commands, String.downcase(plugin_token)) + + _other -> + false + end + end + + def candidate_input?(_input), do: false + defp plugin_id_from_input(input) do input |> String.trim() diff --git a/lib/ourocode/plugin/user_level/registry.ex b/lib/ourocode/plugin/user_level/registry.ex index af4ac6a..3d2b4e8 100644 --- a/lib/ourocode/plugin/user_level/registry.ex +++ b/lib/ourocode/plugin/user_level/registry.ex @@ -65,7 +65,9 @@ defmodule Ourocode.Plugin.UserLevel.Registry do adapter_options: adapter_options } - case Agent.start_link(fn -> initial end, name: name) do + start_opts = if is_nil(name), do: [], else: [name: name] + + case Agent.start_link(fn -> initial end, start_opts) do {:ok, pid} -> if eager?, do: _ = refresh(name) {:ok, pid} diff --git a/lib/ourocode/runtime/application_services.ex b/lib/ourocode/runtime/application_services.ex index a8a6d2c..e509386 100644 --- a/lib/ourocode/runtime/application_services.ex +++ b/lib/ourocode/runtime/application_services.ex @@ -4,6 +4,7 @@ defmodule Ourocode.Runtime.ApplicationServices do """ alias Ourocode.Plugin.ConfigWatcher + alias Ourocode.Plugin.UserLevel.Registry, as: UserLevelRegistry alias Ourocode.Runtime.ApplicationState @spec start(map(), Path.t(), Path.t()) :: @@ -41,6 +42,7 @@ defmodule Ourocode.Runtime.ApplicationServices do agent_child(:pane_model, ApplicationState.pane_model_state()), agent_child(:focus_state, ApplicationState.focus_state()), agent_child(:plugin_registry, ApplicationState.plugin_state(context)), + Supervisor.child_spec({UserLevelRegistry, name: nil}, id: :user_level_plugin_registry), Supervisor.child_spec( {ConfigWatcher, ApplicationState.plugin_config_watcher_options(context, project_dir, journal_path)}, diff --git a/lib/ourocode/runtime/application_state.ex b/lib/ourocode/runtime/application_state.ex index 4206092..2ddb962 100644 --- a/lib/ourocode/runtime/application_state.ex +++ b/lib/ourocode/runtime/application_state.ex @@ -17,6 +17,7 @@ defmodule Ourocode.Runtime.ApplicationState do :pane_model, :focus_state, :plugin_registry, + :user_level_plugin_registry, :plugin_config_watcher, :command_registry, :queued_notifications, diff --git a/lib/ourocode/runtime/loop_binding_workflow_dispatch.ex b/lib/ourocode/runtime/loop_binding_workflow_dispatch.ex index a3e3194..8cf4658 100644 --- a/lib/ourocode/runtime/loop_binding_workflow_dispatch.ex +++ b/lib/ourocode/runtime/loop_binding_workflow_dispatch.ex @@ -14,10 +14,13 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatch do McpDaemonBinding, OuroborosDirectInvocation, OuroborosWorkflowInvocation, + UserLevelPluginInvocation, WorkflowHarness, WorkflowRelay } + alias Ourocode.Plugin.UserLevel.Registry, as: UserLevelRegistry + @ouroboros_adapters %{ {:ouroboros_workflow, :auto} => OuroborosWorkflowInvocation, {:ouroboros, :auto} => OuroborosWorkflowInvocation, @@ -75,7 +78,8 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatch do :ouroboros_tutorial => OuroborosDirectInvocation, {:ouroboros_workflow, :help} => OuroborosDirectInvocation, {:ouroboros, :help} => OuroborosDirectInvocation, - :ouroboros_help => OuroborosDirectInvocation + :ouroboros_help => OuroborosDirectInvocation, + :user_level_plugin => UserLevelPluginInvocation } @type callbacks :: %{ @@ -92,7 +96,7 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatch do @spec handle_prompt(pid(), map(), map(), map(), callbacks()) :: :ok def handle_prompt(agent, runtime, task_request, input_event, callbacks) when is_pid(agent) and is_map(callbacks) do - if ouroboros_route?(task_request) do + if dispatchable_route?(task_request) do parent_call_id = parent_call_id(task_request) workflow_run_id = "workflow-run:" <> parent_call_id @@ -124,6 +128,10 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatch do def ouroboros_route?(%{routing_decision: %{execution_route: :ouroboros_workflow}}), do: true def ouroboros_route?(_task_request), do: false + @spec user_level_route?(map()) :: boolean() + def user_level_route?(%{routing_decision: %{execution_route: :user_level_plugin}}), do: true + def user_level_route?(_task_request), do: false + @spec interview_task?(map()) :: boolean() def interview_task?(%{routing_decision: %{adapter_route: :interview}}), do: true def interview_task?(_task_request), do: false @@ -196,7 +204,7 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatch do model = input_event_model(input_event) || Catalog.default() context = - if direct_task?(task_request) do + if direct_task?(task_request) or user_level_route?(task_request) do %{ cwd: project_dir(runtime), workflow_run_id: workflow_run_id @@ -214,13 +222,16 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatch do Dispatcher.dispatch(task_request, adapters: adapter_registry(), + external_command_runner: external_command_runner(runtime), context: context |> Map.merge(%{ request_id: "req-" <> to_string(task_request.id), parent_call_id: parent_call_id, workflow_run_id: workflow_run_id, - cwd: project_dir(runtime) + cwd: project_dir(runtime), + capabilities: user_level_capabilities(runtime), + decision_journal: journal_path(runtime) }) |> Map.merge(workflow_context(agent)) ) @@ -289,4 +300,50 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatch do end defp interview_payload?(_payload), do: false + + defp dispatchable_route?(task_request) do + ouroboros_route?(task_request) or user_level_route?(task_request) + end + + defp user_level_capabilities(%{services: %{user_level_plugin_registry: pid}}) + when is_pid(pid) do + pid + |> UserLevelRegistry.list() + |> Map.get(:capabilities, []) + rescue + _exception -> [] + end + + defp user_level_capabilities(%{user_level_capabilities: capabilities}) + when is_list(capabilities), + do: capabilities + + defp user_level_capabilities(_runtime), do: [] + + defp journal_path(%{journal: %{path: path}}) when is_binary(path), do: path + defp journal_path(_runtime), do: nil + + defp external_command_runner(%{user_level_external_command_runner: runner}) + when is_function(runner, 3), + do: runner + + defp external_command_runner(_runtime), do: &system_external_command_runner/3 + + defp system_external_command_runner(command, args, opts) do + system_opts = + [] + |> maybe_put_system_opt(:cd, Map.get(opts, :cwd)) + |> maybe_put_system_opt(:env, Map.get(opts, :env)) + + case System.cmd(command, args, system_opts) do + {stdout, status} -> {:ok, %{status: status, stdout: stdout, stderr: ""}} + end + rescue + exception in [ErlangError, File.Error, System.EnvError] -> + {:error, {:external_command_failed, Exception.message(exception)}} + end + + defp maybe_put_system_opt(opts, _key, nil), do: opts + defp maybe_put_system_opt(opts, _key, ""), do: opts + defp maybe_put_system_opt(opts, key, value), do: Keyword.put(opts, key, value) end diff --git a/lib/ourocode/runtime/loop_bindings.ex b/lib/ourocode/runtime/loop_bindings.ex index 36f7145..3380809 100644 --- a/lib/ourocode/runtime/loop_bindings.ex +++ b/lib/ourocode/runtime/loop_bindings.ex @@ -20,6 +20,8 @@ defmodule Ourocode.Runtime.LoopBindings do """ alias Ourocode.Runtime.McpDaemon + alias Ourocode.Plugin.UserLevel.Entry, as: UserLevelEntry + alias Ourocode.Plugin.UserLevel.Registry, as: UserLevelRegistry alias Ourocode.Runtime.{ LoopBindingEventFlow, @@ -203,6 +205,8 @@ defmodule Ourocode.Runtime.LoopBindings do defp on_prompt_input_fun(agent, runtime) do fn task_request, input_event, _startup_result -> + task_request = refine_user_level_route(task_request, runtime) + LoopBindingWorkflowDispatch.handle_prompt( agent, runtime, @@ -272,4 +276,27 @@ defmodule Ourocode.Runtime.LoopBindings do defp mcp_url do System.get_env("OUROCODE_MCP_URL") || "http://127.0.0.1:4000/mcp" end + + defp refine_user_level_route(task_request, runtime) do + if UserLevelEntry.candidate_input?(Map.get(task_request, :task_input)) do + UserLevelEntry.refine(task_request, user_level_capabilities(runtime)) + else + task_request + end + end + + defp user_level_capabilities(%{services: %{user_level_plugin_registry: pid}}) + when is_pid(pid) do + pid + |> UserLevelRegistry.list() + |> Map.get(:capabilities, []) + rescue + _exception -> [] + end + + defp user_level_capabilities(%{user_level_capabilities: capabilities}) + when is_list(capabilities), + do: capabilities + + defp user_level_capabilities(_runtime), do: [] end diff --git a/lib/ourocode/runtime/user_level_plugin_invocation.ex b/lib/ourocode/runtime/user_level_plugin_invocation.ex index 99ff6f2..f6dfc6e 100644 --- a/lib/ourocode/runtime/user_level_plugin_invocation.ex +++ b/lib/ourocode/runtime/user_level_plugin_invocation.ex @@ -121,7 +121,9 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocation do end defp evaluate(%PreflightResult{kind: :ambiguous}, _context), do: {:blocked, :ambiguous_match} - defp evaluate(%PreflightResult{kind: :unknown}, _context), do: {:blocked, :unknown_plugin_or_command} + + defp evaluate(%PreflightResult{kind: :unknown}, _context), + do: {:blocked, :unknown_plugin_or_command} defp evaluate(%PreflightResult{kind: :not_applicable}, _context), do: {:blocked, :not_user_level_plugin_input} @@ -193,7 +195,10 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocation do end end - defp post_execution(%PreflightResult{kind: :unique_match, command: command} = preflight, context) + defp post_execution( + %PreflightResult{kind: :unique_match, command: command} = preflight, + context + ) when not is_nil(command) do cwd = Map.get(context, :cwd) || File.cwd!() @@ -201,9 +206,7 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocation do if command.expected_artifacts == [] do [] else - ArtifactWatcher.scan(command, cwd, - lstat?: Map.get(context, :artifact_lstat?, true) - ) + ArtifactWatcher.scan(command, cwd, lstat?: Map.get(context, :artifact_lstat?, true)) end continuation = Continuation.decide(preflight, artifacts) @@ -232,7 +235,10 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocation do end end - defp argv_for(%PreflightResult{kind: :unique_match, plugin: plugin, command: command, args: args}, _context) do + defp argv_for( + %PreflightResult{kind: :unique_match, plugin: plugin, command: command, args: args}, + _context + ) do [plugin.plugin_id, command.name | args] end @@ -242,7 +248,7 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocation do defp runner_opts(context) do context - |> Map.take([:cwd, :env, :timeout_ms]) + |> Map.take([:cwd, :env, :timeout_ms, :workflow_run_id]) |> Map.new() end end diff --git a/lib/ourocode/terminal/event_loop_prompt_input.ex b/lib/ourocode/terminal/event_loop_prompt_input.ex index c2b5f54..7b12a62 100644 --- a/lib/ourocode/terminal/event_loop_prompt_input.ex +++ b/lib/ourocode/terminal/event_loop_prompt_input.ex @@ -3,6 +3,7 @@ defmodule Ourocode.Terminal.EventLoopPromptInput do Normalizes and dispatches natural-language prompt input events. """ + alias Ourocode.Plugin.UserLevel.Entry, as: UserLevelEntry alias Ourocode.Runtime.FocusState alias Ourocode.TaskRequest @@ -19,6 +20,7 @@ defmodule Ourocode.Terminal.EventLoopPromptInput do |> Map.put(:source, Map.get(options, :task_source, :dashboard)) with {:ok, task_request} <- TaskRequest.parse(line, task_options) do + task_request = refine_user_level_route(task_request, options) {:ok, {task_request, input_event(task_request, Map.put_new(options, :raw_input, line))}} end end @@ -129,13 +131,31 @@ defmodule Ourocode.Terminal.EventLoopPromptInput do @spec task_request_from_event(map()) :: {:ok, TaskRequest.t()} | {:error, term()} def task_request_from_event(input_event) do - TaskRequest.parse(input_event.task_input, - id: input_event.task_request_id, - source: input_event.payload.task_source, - submitted_at_ms: input_event.submitted_at_ms - ) + with {:ok, task_request} <- + TaskRequest.parse(input_event.task_input, + id: input_event.task_request_id, + source: input_event.payload.task_source, + submitted_at_ms: input_event.submitted_at_ms + ) do + {:ok, restore_event_routing_decision(task_request, input_event)} + end + end + + defp restore_event_routing_decision(%TaskRequest{} = task_request, %{routing_decision: decision}) + when is_map(decision) do + %{task_request | routing_decision: decision} end + defp restore_event_routing_decision(%TaskRequest{} = task_request, _input_event), + do: task_request + + defp refine_user_level_route(%TaskRequest{} = task_request, options) do + capabilities = Map.get(options, :user_level_capabilities, []) + UserLevelEntry.refine(task_request, capabilities) + end + + defp refine_user_level_route(task_request, _options), do: task_request + defp pane_directed_steering_message( target_pane_id, content, diff --git a/lib/ourocode/terminal/event_loop_task_submission.ex b/lib/ourocode/terminal/event_loop_task_submission.ex index 697a6d5..8b6a3ad 100644 --- a/lib/ourocode/terminal/event_loop_task_submission.ex +++ b/lib/ourocode/terminal/event_loop_task_submission.ex @@ -3,6 +3,8 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmission do Natural-language task submission path for the terminal event loop. """ + alias Ourocode.Plugin.UserLevel.Entry, as: UserLevelEntry + alias Ourocode.Plugin.UserLevel.Registry, as: UserLevelRegistry alias Ourocode.Terminal.EventLoopJournal alias Ourocode.Terminal.EventLoopPromptFlow alias Ourocode.Terminal.EventLoopPromptInput @@ -13,7 +15,8 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmission do def submit(line, state) when is_binary(line) and is_map(state) do case EventLoopPromptInput.normalize_line(line, focus_state: state.focus_state, - raw_input: line + raw_input: line, + user_level_capabilities: user_level_capabilities(state.startup_result, line) ) do {:ok, {_task_request, input_event}} -> accept_and_dispatch(input_event, state) @@ -60,7 +63,7 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmission do input_event ) - if ouroboros_workflow?(dispatched_task_request) do + if workflow_lane?(dispatched_task_request) do IO.puts(awaiting_state.output, workflow_start_line(dispatched_task_request)) IO.puts(awaiting_state.output, workflow_next_line(dispatched_task_request)) else @@ -103,12 +106,26 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmission do defp ouroboros_workflow?(_task_request), do: false + defp user_level_plugin?(%{routing_decision: routing_decision}) when is_map(routing_decision) do + route = + Map.get(routing_decision, :execution_route) || Map.get(routing_decision, "execution_route") + + route in [:user_level_plugin, "user_level_plugin"] + end + + defp user_level_plugin?(_task_request), do: false + + defp workflow_lane?(task_request) do + ouroboros_workflow?(task_request) or user_level_plugin?(task_request) + end + defp workflow_start_line(task_request) do "#{workflow_label(task_request)}: starting - #{task_request.task_input}" end defp workflow_next_line(task_request) do case workflow_mode(task_request) do + :user_level_plugin -> "plugin: running through Ouroboros UserLevel dispatch" :auto -> "auto: preparing an approval plan before file changes" :interview -> "interview: preparing the first clarification question" :pm -> "pm: preparing the first product question" @@ -117,7 +134,7 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmission do end defp maybe_put_workflow_session(state, task_request) do - if ouroboros_workflow?(task_request) do + if workflow_lane?(task_request) do pane_id = "workflow:" <> task_request.id panes = Map.get(state.pane_model, :panes, %{}) open = Map.get(state.pane_model, :open, []) @@ -146,6 +163,9 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmission do end end + defp workflow_mode(%{routing_decision: %{execution_route: :user_level_plugin}}), + do: :user_level_plugin + defp workflow_mode(%{task_input: input}) when is_binary(input) do normalized = input |> String.trim() |> String.downcase() @@ -168,6 +188,7 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmission do defp workflow_label(task_request) do case workflow_mode(task_request) do + :user_level_plugin -> "UserLevel plugin" :auto -> "Auto run" :interview -> "Socratic interview" :pm -> "PM interview" @@ -175,18 +196,44 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmission do end end + defp workflow_status(:user_level_plugin), do: "preflighting plugin" defp workflow_status(:auto), do: "preparing approval" defp workflow_status(:interview), do: "preparing question" defp workflow_status(:pm), do: "preparing question" defp workflow_status(:workflow), do: "preparing" + defp workflow_last_line(:user_level_plugin), do: "resolving plugin capability and trust" defp workflow_last_line(:auto), do: "interview -> plan -> approval -> verify" defp workflow_last_line(:interview), do: "waiting for first clarification question" defp workflow_last_line(:pm), do: "waiting for first PM question" defp workflow_last_line(:workflow), do: "waiting for the first visible update" + defp workflow_progress(:user_level_plugin), do: "plugin preflight -> guarded run -> artifacts" defp workflow_progress(:auto), do: "approval checkpoint before file changes" defp workflow_progress(:interview), do: "first question pending" defp workflow_progress(:pm), do: "answer choices pending" defp workflow_progress(:workflow), do: "first update pending" + + defp user_level_capabilities(startup_result, line) do + if UserLevelEntry.candidate_input?(line) do + startup_result + |> user_level_registry_pid() + |> case do + pid when is_pid(pid) -> + pid + |> UserLevelRegistry.list() + |> Map.get(:capabilities, []) + + _missing -> + Map.get(startup_result, :user_level_capabilities, []) + end + else + [] + end + rescue + _exception -> [] + end + + defp user_level_registry_pid(%{services: %{user_level_plugin_registry: pid}}), do: pid + defp user_level_registry_pid(_startup_result), do: nil end diff --git a/test/ourocode/cli_test.exs b/test/ourocode/cli_test.exs index 754b480..6185baa 100644 --- a/test/ourocode/cli_test.exs +++ b/test/ourocode/cli_test.exs @@ -476,7 +476,7 @@ defmodule Ourocode.CLITest do orderly?: true, supervisor_alive_before?: true, supervisor_stopped?: true, - service_count: 13, + service_count: 14, services_stopped?: true, released_service_ids: [ :child_supervisor, @@ -491,6 +491,7 @@ defmodule Ourocode.CLITest do :runtime_registry, :session_supervisor, :transport_supervisor, + :user_level_plugin_registry, :wonder_tool ], leaked_service_ids: [], diff --git a/test/ourocode/runtime/application_test.exs b/test/ourocode/runtime/application_test.exs index 9771ae5..9fbb40e 100644 --- a/test/ourocode/runtime/application_test.exs +++ b/test/ourocode/runtime/application_test.exs @@ -47,6 +47,7 @@ defmodule Ourocode.Runtime.ApplicationTest do :pane_model, :focus_state, :plugin_registry, + :user_level_plugin_registry, :plugin_config_watcher, :command_registry, :queued_notifications, diff --git a/test/ourocode/runtime/loop_binding_workflow_dispatch_test.exs b/test/ourocode/runtime/loop_binding_workflow_dispatch_test.exs index d8cdf8d..e6cd776 100644 --- a/test/ourocode/runtime/loop_binding_workflow_dispatch_test.exs +++ b/test/ourocode/runtime/loop_binding_workflow_dispatch_test.exs @@ -2,7 +2,10 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatchTest do use ExUnit.Case, async: true alias Ourocode.Model + alias Ourocode.Plugin.UserLevel.Capability alias Ourocode.Runtime.LoopBindingWorkflowDispatch + alias Ourocode.Runtime.LoopBindings + alias Ourocode.TaskRequest test "ouroboros_route? detects workflow-routed task requests only" do assert LoopBindingWorkflowDispatch.ouroboros_route?(%{ @@ -16,6 +19,16 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatchTest do refute LoopBindingWorkflowDispatch.ouroboros_route?(%{}) end + test "user_level_route? detects UserLevel plugin task requests only" do + assert LoopBindingWorkflowDispatch.user_level_route?(%{ + routing_decision: %{execution_route: :user_level_plugin} + }) + + refute LoopBindingWorkflowDispatch.user_level_route?(%{ + routing_decision: %{execution_route: :ouroboros_workflow} + }) + end + test "interview_task? detects interview adapter route only" do assert LoopBindingWorkflowDispatch.interview_task?(%{ routing_decision: %{adapter_route: :interview} @@ -99,4 +112,75 @@ defmodule Ourocode.Runtime.LoopBindingWorkflowDispatchTest do assert LoopBindingWorkflowDispatch.project_dir(%{}) == File.cwd!() assert LoopBindingWorkflowDispatch.project_dir(nil) == File.cwd!() end + + test "handle_prompt dispatches UserLevel plugin routes through the guarded command runner" do + parent = self() + {:ok, agent} = LoopBindings.start_link() + + task_request = %TaskRequest{ + id: "user-level-dispatch", + source: :dashboard, + task_input: "ooo superpowers list", + submitted_at_ms: System.system_time(:millisecond), + routing_decision: %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers" + } + } + + runtime = %{ + project_dir: File.cwd!(), + user_level_capabilities: [superpowers_capability()], + user_level_external_command_runner: fn command, args, opts -> + send(parent, {:user_level_runner, command, args, opts}) + {:ok, %{status: 0, stdout: "listed", stderr: ""}} + end + } + + assert :ok == + LoopBindingWorkflowDispatch.handle_prompt( + agent, + runtime, + task_request, + %{}, + %{ + enqueue_failure: fn _agent, _parent_call_id, reason -> + send(parent, {:failure, reason}) + :ok + end, + run_interview_session: fn _agent, _opts -> :ok end, + production_parent_call: fn _agent, _runtime, _parent_call_id -> + fn _payload -> :ok end + end, + mcp_url: fn -> "http://127.0.0.1:4000/mcp" end + } + ) + + assert_receive {:user_level_runner, "ouroboros", ["superpowers", "list"], + %{cwd: cwd, workflow_run_id: "workflow-run:parent-user-level-dispatch"}}, + 1_000 + + assert cwd == File.cwd!() + refute_receive {:failure, _reason}, 100 + + LoopBindings.stop(agent) + end + + defp superpowers_capability do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: ["filesystem:read"], + commands: [%{name: "list", risk_class: "read_only"}] + }) + + capability + end end diff --git a/test/ourocode/terminal/event_loop_prompt_input_test.exs b/test/ourocode/terminal/event_loop_prompt_input_test.exs index a37534f..3496629 100644 --- a/test/ourocode/terminal/event_loop_prompt_input_test.exs +++ b/test/ourocode/terminal/event_loop_prompt_input_test.exs @@ -2,6 +2,7 @@ defmodule Ourocode.Terminal.EventLoopPromptInputTest do use ExUnit.Case, async: true alias Ourocode.Runtime.FocusState + alias Ourocode.Plugin.UserLevel.Capability alias Ourocode.Terminal.EventLoopPromptInput test "normalize_line builds a natural-language prompt event with steering metadata" do @@ -89,6 +90,19 @@ defmodule Ourocode.Terminal.EventLoopPromptInputTest do assert input_event.payload.task_input == prompt end + test "normalize_line refines ooo plugin prompts with UserLevel capabilities" do + assert {:ok, {task_request, input_event}} = + EventLoopPromptInput.normalize_line("ooo superpowers list", + id: "prompt-user-level-plugin", + submitted_at_ms: 999, + user_level_capabilities: [superpowers_capability()] + ) + + assert task_request.routing_decision.execution_route == :user_level_plugin + assert task_request.routing_decision.plugin_id == "superpowers" + assert input_event.routing_decision == task_request.routing_decision + end + test "dispatch_event validates the event and invokes the prompt processor" do assert {:ok, {_task_request, input_event}} = EventLoopPromptInput.normalize_line("Add focused tests", @@ -112,6 +126,27 @@ defmodule Ourocode.Terminal.EventLoopPromptInputTest do assert_receive {:processed, "prompt-dispatch-1", "prompt-dispatch-1", %{status: :healthy}} end + test "dispatch_event restores the journaled UserLevel routing decision" do + assert {:ok, {_task_request, input_event}} = + EventLoopPromptInput.normalize_line("ooo superpowers list", + id: "prompt-dispatch-user-level", + submitted_at_ms: 1_001, + user_level_capabilities: [superpowers_capability()] + ) + + parent = self() + + assert {:ok, dispatched} = + EventLoopPromptInput.dispatch_event(input_event, %{status: :healthy}, + on_prompt_input: fn task_request, _event, _startup_result -> + send(parent, {:routed, task_request.routing_decision}) + end + ) + + assert dispatched.routing_decision.execution_route == :user_level_plugin + assert_receive {:routed, %{execution_route: :user_level_plugin, plugin_id: "superpowers"}} + end + test "dispatch_event rejects unsupported or malformed input events" do assert EventLoopPromptInput.dispatch_event( %{type: :slash_command_submitted, input_kind: :slash_command}, @@ -121,4 +156,16 @@ defmodule Ourocode.Terminal.EventLoopPromptInputTest do assert EventLoopPromptInput.dispatch_event(%{input_kind: :natural_language}, %{}) == {:error, :invalid_prompt_input_event} end + + defp superpowers_capability do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: ["filesystem:read"], + commands: [%{name: "list", risk_class: "read_only"}] + }) + + capability + end end diff --git a/test/ourocode/terminal/event_loop_task_submission_test.exs b/test/ourocode/terminal/event_loop_task_submission_test.exs index 306aa1c..c0822f5 100644 --- a/test/ourocode/terminal/event_loop_task_submission_test.exs +++ b/test/ourocode/terminal/event_loop_task_submission_test.exs @@ -5,6 +5,7 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmissionTest do alias Ourocode.Terminal.EventLoopTaskSubmission alias Ourocode.Terminal.WorkspaceModel alias Ourocode.Terminal.WorkspaceText + alias Ourocode.Plugin.UserLevel.Capability alias Ourocode.Journal test "accepts journals dispatches and records a natural-language task" do @@ -177,6 +178,44 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmissionTest do refute output_text =~ "task: queued #{task_id}" end + test "UserLevel plugin submission is routed and shown as a plugin workflow lane" do + {:ok, output} = StringIO.open("") + journal_path = journal_path("task-submission-user-level-plugin") + + state = + EventLoopState.build( + %{status: :healthy, user_level_capabilities: [superpowers_capability()]}, + %{ + journal_path: journal_path, + output: output, + on_prompt_input: fn _task_request, _input_event, _startup_result -> :ok end + }, + "ourocode> " + ) + + assert {:ok, state} = EventLoopTaskSubmission.submit("ooo superpowers list", state) + assert [%{id: task_id, routing_decision: routing}] = state.submitted_tasks + assert routing.execution_route == :user_level_plugin + assert routing.plugin_id == "superpowers" + + pane_id = "workflow:" <> task_id + + assert %{ + title: "UserLevel plugin", + status: "preflighting plugin", + task: "ooo superpowers list", + last_line: "resolving plugin capability and trust", + progress: "plugin preflight -> guarded run -> artifacts" + } = state.pane_model.panes[pane_id] + + assert [%{routing_decision: %{execution_route: :user_level_plugin}}] = state.input_events + + {_input, output_text} = StringIO.contents(output) + assert output_text =~ "UserLevel plugin: starting - ooo superpowers list" + assert output_text =~ "plugin: running through Ouroboros UserLevel dispatch" + refute output_text =~ "task: queued #{task_id}" + end + test "ooo workflow submission appears as an active agents lane" do {:ok, output} = StringIO.open("") journal_path = journal_path("task-submission-agents-lane") @@ -282,4 +321,16 @@ defmodule Ourocode.Terminal.EventLoopTaskSubmissionTest do File.rm(path) path end + + defp superpowers_capability do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: ["filesystem:read"], + commands: [%{name: "list", risk_class: "read_only"}] + }) + + capability + end end