diff --git a/.ai/prompts/global.md b/.ai/prompts/global.md index 7e3d5d0..3a0bae6 100644 --- a/.ai/prompts/global.md +++ b/.ai/prompts/global.md @@ -139,7 +139,7 @@ Before starting any implementation work: ## Linear Interaction -Fantasia issues belong to the `EXT` team and the `Fantasia` project. +Linear CLI issues belong to the `EXT` team and the `Linear CLI` project. There is normally no need to interact with Linear directly. Stokowski should pass the necessary context from the linear issue. @@ -152,7 +152,7 @@ For instance, to Post a new Linear comment (for milestone of your work, guidance for the next stage, and so on. Do not try to maintain or find a single running comment to update: - mise exec -- mix lc issue comment --body-file + mise exec -- mix lc issue --team EXT --project 'Linear CLI' comment --body-file - Write the comment's full content to a file first, then pass its path — never build a multi-line comment as an inline shell argument. diff --git a/.ai/prompts/improvement.md b/.ai/prompts/improvement.md index a42ff8b..3c21745 100644 --- a/.ai/prompts/improvement.md +++ b/.ai/prompts/improvement.md @@ -8,7 +8,7 @@ not approval to create every candidate: require a comment such as With no explicit manifest, fail closed: create zero issues, report that result, and complete. For each approved item, re-check for an equivalent Linear issue, then use only `mise exec -- mix lc issue create` with non-interactive options -and a body file to create it in EXT / Fantasia. Include the source issue, +and a body file to create it in EXT / Linear CLI. Include the source issue, candidate ID, evidence, scope, and acceptance criteria; link it to the source when supported. Do not modify the source issue, merge a PR, or change the repo. diff --git a/.ai/prompts/merge.md b/.ai/prompts/merge.md index 1c25367..4e8bcd4 100644 --- a/.ai/prompts/merge.md +++ b/.ai/prompts/merge.md @@ -27,7 +27,9 @@ the approved PR for **{{ issue.identifier }}**: {{ issue.title }} gh pr merge -sd ``` 5. Update the Linear workpad with the merge confirmation. -6. Move the Linear issue to `Done`. + +The workflow runner owns the transition to `Done`. Do not move the Linear +issue to a terminal state yourself. ## Rework run @@ -50,3 +52,4 @@ If this is a rework run (merge was attempted before but failed): - Make code changes beyond conflict resolution. - Open new PRs. - Skip CI checks. +- Move the source issue to a terminal state. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5ce0de8..44765ef 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -106,7 +106,7 @@ jobs: PULL_REQUEST_TITLE: >- ${{ inputs.pull_request_title != '' && inputs.pull_request_title || github.event.pull_request.title }} - run: mix ci + run: mix deps.get && mix ci working-directory: . burrito_changes: diff --git a/.gitignore b/.gitignore index db0ca80..b4a5161 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ # see RepoTasks.MixProject) - app/'s own equivalents are in app/.gitignore. /_build/ /deps/ + +# Stokowski +.stokowski/ diff --git a/lib/mix/tasks/ci.ex b/lib/mix/tasks/ci.ex index 4129d7e..0e2f1c8 100644 --- a/lib/mix/tasks/ci.ex +++ b/lib/mix/tasks/ci.ex @@ -55,6 +55,7 @@ defmodule Mix.Tasks.Ci do @doc false def run([], shell) do Mix.Tasks.Toolchain.Check.run([]) + shell.("mix", ["deps.get"], []) shell.("mix", ["deps.get"], cd: "app") shell.("./ci/validate_pull_request_title.sh", [], []) shell.("./ci/validate_commit_range.sh", [], []) diff --git a/lib/mix/tasks/toolchain.update.ex b/lib/mix/tasks/toolchain.update.ex new file mode 100644 index 0000000..015a072 --- /dev/null +++ b/lib/mix/tasks/toolchain.update.ex @@ -0,0 +1,80 @@ +defmodule Mix.Tasks.Toolchain.Update do + @shortdoc "Updates the Claude Code and Codex pins in mise.toml" + + @moduledoc """ + #{@shortdoc}. + + mix toolchain.update + + Gets the latest npm versions for Claude Code and Codex. + Then updates their pins in `mise.toml`. + """ + + use Mix.Task + + @tools [ + {"claude", "@anthropic-ai/claude-code"}, + {"codex", "@openai/codex"} + ] + + @npm_registry "https://registry.npmjs.org" + + @impl Mix.Task + def run([]) do + update("mise.toml") + end + + def run(_argv), do: Mix.raise("Usage: mix toolchain.update") + + @doc false + def update(mise_path) do + start_req!() + versions = Enum.map(@tools, fn {tool, package} -> {tool, npm_version!(package)} end) + mise_toml = File.read!(mise_path) + + File.write!(mise_path, update_versions!(mise_toml, versions)) + + Mix.shell().info("Updated Claude Code and Codex pins in #{mise_path}") + end + + @doc false + def update_versions!(mise_toml, versions) do + Enum.reduce(versions, mise_toml, fn {tool, version}, contents -> + pattern = ~r/^#{Regex.escape(tool)}\s*=\s*"[^"]+"$/m + + if Regex.match?(pattern, contents) do + Regex.replace(pattern, contents, "#{tool} = \"#{version}\"") + else + Mix.raise("mise.toml does not pin #{tool}") + end + end) + end + + defp npm_version!(package) do + case Req.get(request_options(package)) do + {:ok, %Req.Response{status: 200, body: %{"latest" => version}}} when is_binary(version) -> + version + + {:ok, %Req.Response{status: 200}} -> + Mix.raise("npm registry response for #{package} does not include a latest version") + + {:ok, %Req.Response{status: status}} -> + Mix.raise("npm registry request for #{package} exited with status #{status}") + + {:error, exception} -> + Mix.raise("npm registry request for #{package} failed: #{Exception.message(exception)}") + end + end + + defp request_options(package) do + [url: "#{@npm_registry}/-/package/#{URI.encode(package, &URI.char_unreserved?/1)}/dist-tags"] + |> Keyword.merge(Application.get_env(:repo_tasks, :npm_req_options, [])) + end + + defp start_req! do + case Application.ensure_all_started(:req) do + {:ok, _started} -> :ok + {:error, reason} -> Mix.raise("Cannot start Req: #{inspect(reason)}") + end + end +end diff --git a/mise.toml b/mise.toml index 379f239..03f9765 100644 --- a/mise.toml +++ b/mise.toml @@ -7,6 +7,9 @@ erlang = "29.0.3" elixir = "1.20.3" python = "3.14.7" +uv = "0.12.18" +codex = "0.157.0" +claude = "2.1.282" [env] #ANTHROPIC_BASE_URL = "http://127.0.0.1:13305" #ANTHROPIC_AUTH_TOKEN = "lemonade" @@ -20,11 +23,11 @@ python = "3.14.7" [tasks.setup] description = "Setup the project" -run = "mix setup" +run = "mix deps.get && mix setup" [tasks.deps] description = "Fetch CLI dependencies" -run = "cd app && mix deps.get" +run = "mix toolchain.update && cd app && mix deps.get" depends = ["setup"] [tasks.linear_profile] diff --git a/mix.exs b/mix.exs index f2d8c12..8a74868 100644 --- a/mix.exs +++ b/mix.exs @@ -5,10 +5,9 @@ defmodule RepoTasks.MixProject do # deliberately its own Mix project, sibling to app/, not nested inside it. # app/ is the CLI itself; this is tooling that operates ON the repo as a # whole (this file's own directory, plus ci/, oci/, .github/workflows/ - - # things app/'s own mix.exs has no business knowing about). Kept - # dependency-free on purpose: every task here just orchestrates other - # already-existing tools (mix release inside app/, the ci/*.sh scripts) - # as child OS processes, never runs them in-process. + # things app/'s own mix.exs has no business knowing about). Its dependencies + # support repo-management tasks and do not become app dependencies. Tasks + # use child processes or direct service requests as needed. def project do [ app: :repo_tasks, @@ -26,6 +25,9 @@ defmodule RepoTasks.MixProject do end defp deps do - [] + [ + {:req, "~> 0.7"}, + {:plug, "~> 1.0", only: :test} + ] end end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..f1cc286 --- /dev/null +++ b/mix.lock @@ -0,0 +1,13 @@ +%{ + "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, + "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, +} diff --git a/test/mix/tasks/toolchain.update_test.exs b/test/mix/tasks/toolchain.update_test.exs new file mode 100644 index 0000000..3a08fd2 --- /dev/null +++ b/test/mix/tasks/toolchain.update_test.exs @@ -0,0 +1,55 @@ +defmodule Mix.Tasks.Toolchain.UpdateTest do + use ExUnit.Case, async: false + + alias Mix.Tasks.Toolchain.Update + + setup do + previous_options = Application.get_env(:repo_tasks, :npm_req_options) + + Application.put_env(:repo_tasks, :npm_req_options, + plug: {Req.Test, __MODULE__}, + retry: false + ) + + on_exit(fn -> + if previous_options do + Application.put_env(:repo_tasks, :npm_req_options, previous_options) + else + Application.delete_env(:repo_tasks, :npm_req_options) + end + end) + end + + test "gets each package version from the npm registry" do + versions = %{ + "/-/package/%40anthropic-ai%2Fclaude-code/dist-tags" => "claude-test-version", + "/-/package/%40openai%2Fcodex/dist-tags" => "codex-test-version" + } + + Req.Test.stub(__MODULE__, fn conn -> + version = Map.fetch!(versions, conn.request_path) + Req.Test.json(conn, %{"latest" => version}) + end) + + mise_path = + Path.join(System.tmp_dir!(), "toolchain-update-#{System.unique_integer([:positive])}.toml") + + File.write!(mise_path, "[tools]\ncodex = \"old\"\nclaude = \"old\"\n") + + on_exit(fn -> File.rm(mise_path) end) + + assert :ok = Update.update(mise_path) + + codex_version = Map.fetch!(versions, "/-/package/%40openai%2Fcodex/dist-tags") + claude_version = Map.fetch!(versions, "/-/package/%40anthropic-ai%2Fclaude-code/dist-tags") + + assert File.read!(mise_path) == + "[tools]\ncodex = \"#{codex_version}\"\nclaude = \"#{claude_version}\"\n" + end + + test "requires every tool to have a pin" do + assert_raise Mix.Error, "mise.toml does not pin codex", fn -> + Update.update_versions!(~s([tools]\nclaude = "placeholder"\n), [{"codex", "placeholder"}]) + end + end +end diff --git a/vendor/stokowski b/vendor/stokowski index 79fa1bb..35f4a55 160000 --- a/vendor/stokowski +++ b/vendor/stokowski @@ -1 +1 @@ -Subproject commit 79fa1bb032fdb4517a28a2f6dcf074ff4571829e +Subproject commit 35f4a5585082f75b90b6ce06a23aab066ece535a