Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .ai/prompts/global.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <ISSUE_ID> --body-file <path>
mise exec -- mix lc issue --team EXT --project 'Linear CLI' comment <ISSUE_ID> --body-file <path>

- 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.
Expand Down
2 changes: 1 addition & 1 deletion .ai/prompts/improvement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 4 additions & 1 deletion .ai/prompts/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ the approved PR for **{{ issue.identifier }}**: {{ issue.title }}
gh pr merge -sd <number>
```
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

Expand All @@ -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.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@
# see RepoTasks.MixProject) - app/'s own equivalents are in app/.gitignore.
/_build/
/deps/

# Stokowski
.stokowski/
1 change: 1 addition & 0 deletions lib/mix/tasks/ci.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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", [], [])
Expand Down
80 changes: 80 additions & 0 deletions lib/mix/tasks/toolchain.update.ex
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]
Expand Down
12 changes: 7 additions & 5 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +25,9 @@ defmodule RepoTasks.MixProject do
end

defp deps do
[]
[
{:req, "~> 0.7"},
{:plug, "~> 1.0", only: :test}
]
end
end
13 changes: 13 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
}
55 changes: 55 additions & 0 deletions test/mix/tasks/toolchain.update_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading