From cc11a88c230fbe412212e0afe07d51594b6e1d9a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 28 Apr 2026 13:49:34 +0200 Subject: [PATCH 1/9] Add claude-command CLI subcommand for skills to invoke send-interrupt-signal --- .../SendInterruptSignalCommand.cs | 30 +++++++++++++++++++ .../Commands/ClaudeCommandCommand.cs | 12 ++++++++ developer-cli/Program.cs | 7 +++-- 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 developer-cli/Commands/ClaudeCommand/SendInterruptSignalCommand.cs create mode 100644 developer-cli/Commands/ClaudeCommandCommand.cs diff --git a/developer-cli/Commands/ClaudeCommand/SendInterruptSignalCommand.cs b/developer-cli/Commands/ClaudeCommand/SendInterruptSignalCommand.cs new file mode 100644 index 000000000..9a234f745 --- /dev/null +++ b/developer-cli/Commands/ClaudeCommand/SendInterruptSignalCommand.cs @@ -0,0 +1,30 @@ +using System.CommandLine; + +namespace DeveloperCli.Commands.ClaudeCommand; + +internal sealed class SendInterruptSignalCommand : Command +{ + private readonly Option _teamOption = new("--team", "-t") { Description = "Team name", Required = true }; + + private readonly Option _agentOption = new("--agent", "-a") { Description = "Target agent name", Required = true }; + + public SendInterruptSignalCommand() : base("send-interrupt-signal", "Send an interrupt signal to a team agent") + { + Options.Add(_teamOption); + Options.Add(_agentOption); + SetAction(parseResult => Execute(parseResult.GetValue(_teamOption)!, parseResult.GetValue(_agentOption)!)); + } + + private static void Execute(string team, string agent) + { + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var signalsDirectory = Path.Combine(homeDirectory, ".claude", "teams", team, "signals"); + Directory.CreateDirectory(signalsDirectory); + + var interruptId = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd:HH:mm.ss"); + var signalFilePath = Path.Combine(signalsDirectory, $"{agent}.signal"); + File.WriteAllText(signalFilePath, $"Stop working until you see message #{interruptId}"); + + Console.WriteLine($"#{interruptId}"); + } +} diff --git a/developer-cli/Commands/ClaudeCommandCommand.cs b/developer-cli/Commands/ClaudeCommandCommand.cs new file mode 100644 index 000000000..4fdf1b7ca --- /dev/null +++ b/developer-cli/Commands/ClaudeCommandCommand.cs @@ -0,0 +1,12 @@ +using System.CommandLine; +using DeveloperCli.Commands.ClaudeCommand; + +namespace DeveloperCli.Commands; + +public class ClaudeCommandCommand : Command +{ + public ClaudeCommandCommand() : base("claude-command", "Commands invoked by Claude Code skills") + { + Subcommands.Add(new SendInterruptSignalCommand()); + } +} diff --git a/developer-cli/Program.cs b/developer-cli/Program.cs index ab10905f0..6f2135fee 100644 --- a/developer-cli/Program.cs +++ b/developer-cli/Program.cs @@ -23,11 +23,11 @@ WorkspaceHelper.EnsureWorkspace(); -// Check if running MCP command - skip all output to keep stdout clean for MCP protocol -var isMcpCommand = args.Length > 0 && args[0] == "mcp"; +// Skip preamble for commands that need a clean stdout (MCP protocol, skills parsing CLI output). +var isQuietCommand = args.Length > 0 && (args[0] == "mcp" || args[0] == "claude-command"); var solutionName = new DirectoryInfo(Configuration.SourceCodeFolder).Name; -if (!isMcpCommand && !args.Contains("-q") && !args.Contains("--quiet")) +if (!isQuietCommand && !args.Contains("-q") && !args.Contains("--quiet")) { if (args.Length == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "-?")) { @@ -54,6 +54,7 @@ var allCommands = Assembly.GetExecutingAssembly().GetTypes() .Where(t => !t.IsAbstract && t.IsAssignableTo(typeof(Command))) + .Where(t => t.Namespace != "DeveloperCli.Commands.ClaudeCommand") .Select(Activator.CreateInstance) .Cast() .ToList(); From a87b674bf611dde203ccd6afa386d5ca9184b460 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 28 Apr 2026 14:58:21 +0200 Subject: [PATCH 2/9] Add per-command Claude Code skills for build, test, format, lint, e2e, aspire-restart, team-interrupt --- .claude/skills/aspire-restart/SKILL.md | 39 +++++++++++++++++++++++ .claude/skills/build/SKILL.md | 32 +++++++++++++++++++ .claude/skills/e2e/SKILL.md | 44 ++++++++++++++++++++++++++ .claude/skills/format/SKILL.md | 35 ++++++++++++++++++++ .claude/skills/lint/SKILL.md | 35 ++++++++++++++++++++ .claude/skills/team-interrupt/SKILL.md | 23 ++++++++++++++ .claude/skills/test/SKILL.md | 36 +++++++++++++++++++++ 7 files changed, 244 insertions(+) create mode 100644 .claude/skills/aspire-restart/SKILL.md create mode 100644 .claude/skills/build/SKILL.md create mode 100644 .claude/skills/e2e/SKILL.md create mode 100644 .claude/skills/format/SKILL.md create mode 100644 .claude/skills/lint/SKILL.md create mode 100644 .claude/skills/team-interrupt/SKILL.md create mode 100644 .claude/skills/test/SKILL.md diff --git a/.claude/skills/aspire-restart/SKILL.md b/.claude/skills/aspire-restart/SKILL.md new file mode 100644 index 000000000..275207fb8 --- /dev/null +++ b/.claude/skills/aspire-restart/SKILL.md @@ -0,0 +1,39 @@ +--- +name: aspire-restart +description: Start or restart the .NET Aspire AppHost via the developer CLI. Always use this, never the developer CLI's `run` command, `aspire run`, or `aspire restart`. +--- + +# Restart Aspire + +```bash +dotnet run --project developer-cli -- restart [] +``` + +Use `developer-cli` exactly as written - do not expand to an absolute worktree path. + +Stops any running Aspire AppHost and starts a fresh instance. Detached by default - the CLI returns immediately while Aspire keeps running. + +Always use `restart`, even when nothing is running yet. It is a no-op when Aspire is not up, and the safe default in every other case. Never use the developer CLI's `run` command, `aspire run`, or `aspire restart`. + +- `` - optional positional argument; written to `.workspace/port.txt` before Aspire starts +- `--public-url ` - set `PUBLIC_URL` (e.g. an ngrok URL) + +## When to use + +- After backend or frontend changes - `restart` is the safe default; it works whether Aspire is up or not. +- To run database migrations. +- When hot reload breaks or stops picking up changes. +- Before running e2e tests on a fresh stack. + +## Picking the base port + +- In the git root: omit `` (uses the default). +- In a worktree: on the first start, pick the first free port from `10000`, `11000`, `12000`, `13000` (`lsof -i :` on macOS/Linux, `netstat -ano | findstr :` on Windows). Keep using that port for the lifetime of the worktree - never change it after. + +## Output is fire-and-forget + +The CLI prints `Aspire is restarting on https://app.dev.localhost:` and exits before Aspire is fully ready. + +## Stopping Aspire + +To stop without restarting, run `dotnet run --project developer-cli -- stop`. Rarely needed - prefer `restart` for everyday use. diff --git a/.claude/skills/build/SKILL.md b/.claude/skills/build/SKILL.md new file mode 100644 index 000000000..f3401e40e --- /dev/null +++ b/.claude/skills/build/SKILL.md @@ -0,0 +1,32 @@ +--- +name: build +description: Build (compile) the solution via the developer CLI - backend (.NET), frontend (React/TypeScript), and the developer CLI itself. +--- + +# Build + +```bash +dotnet run --project developer-cli -- build [--backend] [--frontend] [--cli] [--self-contained-system ] --quiet +``` + +Use `developer-cli` exactly as written - do not expand to an absolute worktree path. + +- `--backend` - .NET +- `--frontend` - React/TypeScript +- `--cli` - the developer CLI itself +- `--self-contained-system ` - narrows the backend build to one SCS (e.g. `account`, `main`) + +No arguments builds everything. + +## Examples + +```bash +dotnet run --project developer-cli -- build --quiet # everything +dotnet run --project developer-cli -- build --backend --quiet # all backend +dotnet run --project developer-cli -- build --frontend --quiet # frontend +dotnet run --project developer-cli -- build --backend --self-contained-system main --quiet # one SCS +``` + +## Always pass --quiet + +Verbose output goes to a log file. On success the CLI prints a single line; on failure it prints a short error summary - read the log if you need the full errors. Without `--quiet` the build floods the conversation. diff --git a/.claude/skills/e2e/SKILL.md b/.claude/skills/e2e/SKILL.md new file mode 100644 index 000000000..4735c068b --- /dev/null +++ b/.claude/skills/e2e/SKILL.md @@ -0,0 +1,44 @@ +--- +name: e2e +description: Run end-to-end Playwright tests via the developer CLI. +--- + +# End-to-end Tests + +```bash +dotnet run --project developer-cli -- e2e [search-terms...] [--smoke] [--browser ] [--retries ] [--last-failed] [--only-changed] [--stop-on-first-failure] [--include-slow] [--self-contained-system ] [--no-wait-for-aspire] --quiet +``` + +Use `developer-cli` exactly as written - do not expand to an absolute worktree path. + +- `search-terms` - filter by test name, tag, or spec file +- `--smoke` - smoke suite only +- `--browser ` - `chromium` (default), `firefox`, `webkit`, `safari`, `all` +- `--retries ` - max retries for flaky tests +- `--last-failed` - re-run only the previous run's failures +- `--only-changed` - only spec files with uncommitted changes +- `--stop-on-first-failure`, `-x` - exit on first failure +- `--include-slow` - include `@slow`-tagged tests (excluded by default) +- `--self-contained-system ` - narrows to one SCS (e.g. `main`, `account`) +- `--no-wait-for-aspire` - skip the Aspire readiness check (use only when Aspire is already up) + +Aspire must be running. If not, start it via the `aspire-restart` skill first. + +## Strategy: pick the smallest run that proves the fix + +E2E runs are slow. Never blindly run the full suite to verify a small change. + +- Verifying a recent edit? `--only-changed`. +- After a fix? `--last-failed`. +- Iterating on one test? Pass a search term: a name (`"user management"`), a tag, or a spec file. +- Add `--stop-on-first-failure` (`-x`) when iterating so the run aborts on the first failure. +- Expecting failures? Start with `--smoke`, get those green, then run the rest. +- Only widen to `firefox` / `webkit` after `chromium` passes - and skip `chromium` on those runs (`--browser firefox`). + +## Flaky tests need load + +A flaky test run in isolation often passes - the bug only shows under parallel load. Reproduce with the full file or suite before declaring it fixed. + +## Always pass --quiet + +Without `--quiet` Playwright streams every step to the conversation. On success the CLI prints a single summary; on failure it prints the failed tests and where to find the report. diff --git a/.claude/skills/format/SKILL.md b/.claude/skills/format/SKILL.md new file mode 100644 index 000000000..f4e4eb6b4 --- /dev/null +++ b/.claude/skills/format/SKILL.md @@ -0,0 +1,35 @@ +--- +name: format +description: Auto-format code via the developer CLI - backend (.NET via JetBrains cleanupcode), frontend (oxfmt + oxlint --fix), and the developer CLI itself. +--- + +# Format + +```bash +dotnet run --project developer-cli -- format [--backend] [--frontend] [--cli] [--self-contained-system ] [--no-build] --quiet +``` + +Use `developer-cli` exactly as written - do not expand to an absolute worktree path. + +- `--backend` - .NET (JetBrains cleanupcode) +- `--frontend` - React/TypeScript (oxfmt + oxlint --fix) +- `--cli` - the developer CLI itself +- `--self-contained-system ` - narrows backend formatting to one SCS (e.g. `account`, `main`) +- `--no-build` - skip the `dotnet tool restore` step (faster after a recent run) + +No arguments formats everything. Unformatted code fails CI - commit all changes, never revert. + +After `build` succeeds, run `format`, `lint`, `test` in parallel with `--no-build`. + +## Examples + +```bash +dotnet run --project developer-cli -- format --quiet # everything +dotnet run --project developer-cli -- format --backend --quiet # all backend +dotnet run --project developer-cli -- format --frontend --quiet # frontend +dotnet run --project developer-cli -- format --backend --self-contained-system account --quiet # one SCS +``` + +## Always pass --quiet + +Verbose output goes to a log file. On success the CLI prints a single line; on failure it prints a short error message - read the log if you need details. Backend is slow - run last. Frontend is fast. diff --git a/.claude/skills/lint/SKILL.md b/.claude/skills/lint/SKILL.md new file mode 100644 index 000000000..48bffa3b8 --- /dev/null +++ b/.claude/skills/lint/SKILL.md @@ -0,0 +1,35 @@ +--- +name: lint +description: Lint code via the developer CLI - backend (.NET via JetBrains inspectcode), frontend (oxlint), and the developer CLI itself. +--- + +# Lint + +```bash +dotnet run --project developer-cli -- lint [--backend] [--frontend] [--cli] [--self-contained-system ] [--no-build] --quiet +``` + +Use `developer-cli` exactly as written - do not expand to an absolute worktree path. + +- `--backend` - .NET (JetBrains inspectcode) +- `--frontend` - React/TypeScript (oxlint) +- `--cli` - the developer CLI itself +- `--self-contained-system ` - narrows backend linting to one SCS (e.g. `account`, `main`) +- `--no-build` - skip the rebuild step (faster after a recent build) + +No arguments lints everything. Every finding fails CI regardless of severity - fix all of them. + +After `build` succeeds, run `format`, `lint`, `test` in parallel with `--no-build`. Backend lint is slow - run last. Frontend lint often needs code rewrites - run after each bigger change. + +## Examples + +```bash +dotnet run --project developer-cli -- lint --quiet # everything +dotnet run --project developer-cli -- lint --backend --quiet # all backend +dotnet run --project developer-cli -- lint --frontend --quiet # frontend +dotnet run --project developer-cli -- lint --backend --self-contained-system main --quiet # one SCS +``` + +## Always pass --quiet + +Verbose output goes to a log file. On success the CLI prints a single line; on failure it prints where to find the findings and exits 1. diff --git a/.claude/skills/team-interrupt/SKILL.md b/.claude/skills/team-interrupt/SKILL.md new file mode 100644 index 000000000..0f237d453 --- /dev/null +++ b/.claude/skills/team-interrupt/SKILL.md @@ -0,0 +1,23 @@ +--- +name: team-interrupt +description: Send an interrupt signal to a working team agent. Use when an agent is actively running and you need to send it a message without having to wait until it has processed all other messages. +--- + +# Team Interrupt + +`SendMessage` is for idle/hibernated agents only. If the agent is working, the message sits in the queue and is processed later against stale context - answers will be obsolete and queued instructions may already be wrong. To reach a working agent, use Interrupt + `SendMessage`. Never queue status checks, redirects, or corrections behind active work. + +## Procedure + +1. Run (use `developer-cli` exactly as written - do not expand to an absolute worktree path): + ```bash + dotnet run --project developer-cli -- claude-command send-interrupt-signal --team --agent + ``` +2. Capture the stdout line - a single `#` interrupt ID. +3. Send a follow-up `SendMessage` to the same agent with the body prefixed by that ID: + ``` + # + ``` +4. STOP - no follow-ups. + +The ID links the signal to the correct queued message; the active agent skips stale messages until it sees the matching ID. diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md new file mode 100644 index 000000000..7e09282f1 --- /dev/null +++ b/.claude/skills/test/SKILL.md @@ -0,0 +1,36 @@ +--- +name: test +description: Run backend (.NET) xUnit unit and integration tests via the developer CLI. +--- + +# Test + +```bash +dotnet run --project developer-cli -- test [--self-contained-system ] [--filter ] [--no-build] [--exclude-category ] --quiet +``` + +Use `developer-cli` exactly as written - do not expand to an absolute worktree path. + +Backend only - there is no frontend test runner. + +- `--self-contained-system ` - narrows to one SCS (e.g. `account`, `main`) +- `--filter ` - forwarded to `dotnet test --filter` to scope to a subset of tests +- `--no-build` - skip rebuild before running (faster after a recent build) +- `--exclude-category ` - defaults to `Noisy`; pass an empty string to include them + +No arguments runs every test across every SCS. + +After `build` succeeds, run `format`, `lint`, `test` in parallel with `--no-build`. + +## Examples + +```bash +dotnet run --project developer-cli -- test --quiet # all tests +dotnet run --project developer-cli -- test --self-contained-system account --quiet # one SCS +dotnet run --project developer-cli -- test --filter "FullyQualifiedName~LoginTests" --quiet # filter by name +dotnet run --project developer-cli -- test --no-build --quiet # after a recent build +``` + +## Always pass --quiet + +Verbose output goes to a log file. On success the CLI prints a one-line summary (totals + duration); on failure it lists the failed test names plus the log path - read the log if you need stack traces. Without `--quiet` every test result streams into the conversation. From b156faa47e54c14162d915c86a02fef447645950 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 28 Apr 2026 15:02:16 +0200 Subject: [PATCH 3/9] Switch AGENTS.md and bash hook from MCP tools to skill-first guidance --- .claude/hooks/pre-tool-use-bash.sh | 18 +++++++++--------- AGENTS.md | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.claude/hooks/pre-tool-use-bash.sh b/.claude/hooks/pre-tool-use-bash.sh index 7d132e51c..a18204849 100755 --- a/.claude/hooks/pre-tool-use-bash.sh +++ b/.claude/hooks/pre-tool-use-bash.sh @@ -1,7 +1,7 @@ #!/bin/bash # Pre-tool-use hook for Bash commands -# This hook blocks certain commands and enforces MCP tool usage +# Blocks direct dotnet/npm/playwright invocations and points at the developer CLI skills. # Read the JSON input from stdin input=$(cat) @@ -11,13 +11,13 @@ cmd=$(echo "$input" | sed -n 's/.*"command":"\([^"]*\)".*/\1/p') # Check the command and decide whether to block it case "$cmd" in - "cd "*|*" && cd "*|*";cd "*|*"; cd "*) echo "❌ Do not change directory. Use absolute paths or --project flags instead." >&2; exit 2 ;; - *"dotnet build"*) echo "❌ Use **build MCP tool** instead" >&2; exit 2 ;; - *"dotnet test"*) echo "❌ Use **test MCP tool** instead" >&2; exit 2 ;; - *"dotnet format"*) echo "❌ Use **format MCP tool** instead" >&2; exit 2 ;; - *"npm run format"*) echo "❌ Use **format MCP tool** instead" >&2; exit 2 ;; - *"npm test"*) echo "❌ Use **test MCP tool** instead" >&2; exit 2 ;; - *"npm run build"*) echo "❌ Use **build MCP tool** instead" >&2; exit 2 ;; - *"npx playwright test"*) echo "❌ Use **end-to-end MCP tool** instead" >&2; exit 2 ;; + "cd "*|*" && cd "*|*";cd "*|*"; cd "*) echo "❌ Do not change directory. Use --project flags or relative paths from the repo root." >&2; exit 2 ;; + *"dotnet build"*) echo "❌ Use the **build** skill (\`dotnet run --project developer-cli -- build --quiet\`)" >&2; exit 2 ;; + *"dotnet test"*) echo "❌ Use the **test** skill (\`dotnet run --project developer-cli -- test --quiet\`)" >&2; exit 2 ;; + *"dotnet format"*) echo "❌ Use the **format** skill (\`dotnet run --project developer-cli -- format --quiet\`)" >&2; exit 2 ;; + *"npm run format"*) echo "❌ Use the **format** skill (\`dotnet run --project developer-cli -- format --quiet\`)" >&2; exit 2 ;; + *"npm test"*) echo "❌ Use the **test** skill (\`dotnet run --project developer-cli -- test --quiet\`)" >&2; exit 2 ;; + *"npm run build"*) echo "❌ Use the **build** skill (\`dotnet run --project developer-cli -- build --quiet\`)" >&2; exit 2 ;; + *"npx playwright test"*) echo "❌ Use the **e2e** skill (\`dotnet run --project developer-cli -- e2e --quiet\`)" >&2; exit 2 ;; *) exit 0 ;; esac diff --git a/AGENTS.md b/AGENTS.md index e5f20b6f5..66af03af1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,12 @@ ## Build, Test, and Format -Always use MCP tools (`build`, `test`, `format`, `lint`, `run`, `restart`, `stop`, `end_to_end`) instead of running dotnet/npm/npx commands directly. Run `build` first, then remaining tools with `noBuild=true`. +Use the developer CLI skills (`build`, `test`, `format`, `lint`, `e2e`, `aspire-restart`, `team-interrupt`) for all code workflows. They invoke `dotnet run --project developer-cli -- ` directly. Never run `dotnet`, `npm`, or `npx` directly - the pre-tool-use Bash hook blocks them. -On MCP failures fall back to the developer CLI directly via `cd developer-cli && dotnet run -- --quiet` (e.g., `cd developer-cli && dotnet run -- build --quiet`, `cd developer-cli && dotnet run -- test --quiet`). Do NOT use the global `pp` shim -- it is bound to the original install path and ignores the current worktree. +Run `build` first, then `format`, `lint`, `test` in parallel with `--no-build`. -**Slow:** Aspire restart, backend format, backend lint, end-to-end tests. **Fast:** frontend format/lint, backend test. If any slow operation is needed, run everything in parallel Task agents. End-to-end tests use `waitForAspire=true`. +**Slow:** Aspire restart, backend format, backend lint, end-to-end tests. **Fast:** frontend format/lint, backend test. -**Aspire**: The `run`, `restart`, and `stop` MCP tools manage the AppHost. Call the `get_ports` MCP tool to look up service URLs. Use `restart` when backend changes or hot reload breaks. In the agentic workflow, only the Guardian agent calls these. All other agents must notify the Guardian if they need Aspire restarted. +**Aspire**: The `aspire-restart` skill manages the AppHost - always use it; never `aspire run`, `aspire restart`, or the developer CLI's `run` command. Use the Aspire MCP `list_resources` tool to look up service URLs (or read `.workspace/port.txt` if you only need the base port). In the agentic workflow, only the Guardian agent restarts Aspire. All other agents must notify the Guardian if they need it restarted. Never commit, amend, or revert without explicit user instruction each time. Commit messages: one descriptive line in imperative form, no description body. From fa96c622399ad365cddb1475e281abe3bd54e0a3 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 28 Apr 2026 15:08:41 +0200 Subject: [PATCH 4/9] Switch agent docs and rules from MCP tools to skill-first guidance and team-interrupt skill --- .claude/agents/architect.md | 2 +- .claude/agents/backend-reviewer.md | 2 +- .claude/agents/backend.md | 6 ++--- .claude/agents/frontend-reviewer.md | 2 +- .claude/agents/frontend.md | 4 ++-- .claude/agents/guardian.md | 8 +++---- .claude/agents/pair-programmer.md | 10 ++++----- .claude/agents/qa-reviewer.md | 4 ++-- .claude/agents/qa.md | 6 ++--- .claude/agents/regression-tester.md | 4 ++-- .claude/agents/researcher.md | 2 +- .claude/agents/team-lead.md | 8 +++---- .claude/commands/prepare-pull-request.md | 22 +++++++++---------- .claude/rules/ai-rules/ai-rules.md | 6 ++--- .claude/rules/backend/backend.md | 10 ++++----- .../end-to-end-tests/end-to-end-tests.md | 10 ++++----- .claude/rules/frontend/frontend.md | 6 ++--- .claude/skills/fix-e2e-tests/SKILL.md | 2 +- 18 files changed, 56 insertions(+), 58 deletions(-) diff --git a/.claude/agents/architect.md b/.claude/agents/architect.md index 5d7c97353..fa504eda6 100644 --- a/.claude/agents/architect.md +++ b/.claude/agents/architect.md @@ -84,4 +84,4 @@ Notify the team lead with your findings when done. Before going idle, always not - Never send more than one message to the same agent without getting a response - Be specific: file paths, concrete details - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it diff --git a/.claude/agents/backend-reviewer.md b/.claude/agents/backend-reviewer.md index 60f085ae7..d9a58ad09 100644 --- a/.claude/agents/backend-reviewer.md +++ b/.claude/agents/backend-reviewer.md @@ -125,4 +125,4 @@ If the [task] is not in [Active] when you start, stop and escalate. If blocked a - When the engineer pushes back with evidence, evaluate objectively - Escalate unresolvable disagreements to the team lead - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it diff --git a/.claude/agents/backend.md b/.claude/agents/backend.md index 132fb698e..c10c11b2d 100644 --- a/.claude/agents/backend.md +++ b/.claude/agents/backend.md @@ -72,8 +72,8 @@ If you need to add changes after submitting for review (e.g., a new endpoint the ### Communication During Work -- Notify the frontend engineer (SendMessage) when contract changes affect their work. Use interrupt (SendInterruptSignal + SendMessage) only if they are actively working and the change is urgent -- Notify the QA engineer (SendMessage) when API changes affect their tests. Use interrupt (SendInterruptSignal + SendMessage) only if tests are actively running against stale contracts +- Notify the frontend engineer (SendMessage) when contract changes affect their work. Use interrupt (use the **team-interrupt** skill) only if they are actively working and the change is urgent +- Notify the QA engineer (SendMessage) when API changes affect their tests. Use interrupt (use the **team-interrupt** skill) only if tests are actively running against stale contracts - Work autonomously. No progress updates to the team lead ### Task Scope @@ -123,5 +123,5 @@ After the Guardian commits, call TaskList for your next assignment. Claim with T - Be specific: file paths, line numbers, concrete details - Only notify the team lead when blocked or done with all work - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it diff --git a/.claude/agents/frontend-reviewer.md b/.claude/agents/frontend-reviewer.md index 4a849d0f8..0a492f0fa 100644 --- a/.claude/agents/frontend-reviewer.md +++ b/.claude/agents/frontend-reviewer.md @@ -136,4 +136,4 @@ If the [task] is not in [Active] when you start, stop and escalate. If blocked a - When the engineer pushes back with evidence, evaluate objectively - Escalate unresolvable disagreements to the team lead - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it diff --git a/.claude/agents/frontend.md b/.claude/agents/frontend.md index 078562b2e..5e6d15540 100644 --- a/.claude/agents/frontend.md +++ b/.claude/agents/frontend.md @@ -87,7 +87,7 @@ You may use Claude in Chrome for development troubleshooting (console errors, ne ### Communication During Work - Notify the QA engineer (SendMessage) when UI or contract changes affect their tests. Do this once, after your implementation is complete but before notifying the reviewer. Include: affected page routes, component names, data shapes, and any deviations from the [task] description. The team lead will tell you the QA engineer's name in the task assignment. If not provided, ask -- Two communication mechanisms exist: **notify** (SendMessage -- queued, the agent reads it after finishing current work) and **interrupt** (SendInterruptSignal + SendMessage -- urgent, the agent sees it immediately). Use notify for informational updates (e.g., telling QA about your component structure). Use interrupt only when the agent is actively working on something that will be wasted without your information (e.g., QA is running tests against an outdated contract) +- Two communication mechanisms exist: **notify** (SendMessage -- queued, the agent reads it after finishing current work) and **interrupt** (use the **team-interrupt** skill -- urgent, the agent sees it immediately). Use notify for informational updates (e.g., telling QA about your component structure). Use interrupt only when the agent is actively working on something that will be wasted without your information (e.g., QA is running tests against an outdated contract) - Work autonomously. No progress updates to the team lead ### Task Scope @@ -133,5 +133,5 @@ After the Guardian commits, call TaskList for your next assignment. Claim with T - Be specific: file paths, line numbers, concrete details - Only notify the team lead when blocked or done with all work - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it diff --git a/.claude/agents/guardian.md b/.claude/agents/guardian.md index f063d28ff..82c0a0f3d 100644 --- a/.claude/agents/guardian.md +++ b/.claude/agents/guardian.md @@ -20,7 +20,7 @@ You persist across the entire [feature]. You maintain context across all tasks. ## Core Responsibilities 1. **All git commits** -2. **All Aspire restarts** via the `run` or `restart` MCP tool +2. **All Aspire restarts** via the **aspire-restart** skill 3. **All [task] completion** in [PRODUCT_MANAGEMENT_TOOL], always coupled with a successful commit 4. **Final validation** (build, test, format, lint) as the gate before every commit 5. **Up to three commits per task set** in dependency order: backend, frontend, E2E @@ -50,7 +50,7 @@ Once all approvals are received and staged, run: 1. **Build** (both backend and frontend if backend changed) 2. **Test** (backend) 3. **Aspire restart** (always, before smoke tests) -4. **Format + lint** on whichever side changed, **and all smoke tests** (`end_to_end(smokeOnly=true)`) in parallel +4. **Format + lint** on whichever side changed, **and all smoke tests** (`dotnet run --project developer-cli -- e2e --smoke --quiet`) in parallel Refuse to commit on any failure and report to the relevant reviewer. If format modifies files, stage them with `git add`. Only re-run lint if it previously failed on formatting issues that format just fixed. @@ -82,7 +82,7 @@ Once validation passes: ## Aspire Restart -Only you manage Aspire via the `run` or `restart` MCP tool. Rules: +Only you manage Aspire via the **aspire-restart** skill. Rules: - When any agent needs Aspire restarted, they notify you with the reason - Restart Aspire as part of Validation Before Commit, before the parallel format/lint + smoke tests step @@ -129,4 +129,4 @@ Before going idle, always notify the team lead with your current status. - Never send more than one message to the same agent without getting a response - Be specific: file paths, validation results, concrete details - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Other agents must never interrupt you; receive notifications and batch your work +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Other agents must never interrupt you; receive notifications and batch your work diff --git a/.claude/agents/pair-programmer.md b/.claude/agents/pair-programmer.md index 361cb4176..1665fae5f 100644 --- a/.claude/agents/pair-programmer.md +++ b/.claude/agents/pair-programmer.md @@ -22,15 +22,15 @@ This applies to every new task, not just large ones. Small tasks get brief plans ## How You Work - You are the user's hands-on collaborator, a senior engineer pair-programming with them -- Work directly: read files, edit code, run MCP tools, execute commands +- Work directly: read files, edit code, invoke skills, execute commands - Commit code when the user explicitly asks (never autonomously) - Follow the same commit message conventions: one descriptive line in imperative form, no description body ## What You Follow - All rules in `.claude/rules/` apply to you: backend, frontend, E2E, infrastructure, all of them -- Use MCP tools (build, test, format, lint, run, end_to_end) instead of running dotnet/npm/npx commands directly -- Run `build` first, then remaining tools with `noBuild=true` +- Use the developer CLI skills (build, test, format, lint, e2e, aspire-restart, team-interrupt) instead of running dotnet/npm/npx commands directly +- Run `build` first, then `format`, `lint`, `test` in parallel with `--no-build` - Use Perplexity for online research instead of Web Search ## Scope @@ -101,12 +101,12 @@ Never assign work to an agent outside its type. If no agent of the correct type **SendMessage** queues a message the agent receives after completing its current task. Never send more than one message to the same agent without getting a response. -**Interrupt signal**: For urgent communication with a working agent. Call `SendInterruptSignal` with your message. The tool returns an interrupt ID. Then send one SendMessage: "#INTERRUPT_ID [actual instructions]" using that ID. +**Interrupt signal**: For urgent communication with a working agent, use the **team-interrupt** skill - it returns an interrupt ID (a single `#` line). Then send one SendMessage prefixed with that ID: `# [actual instructions]`. Tell agents to communicate directly: engineers notify reviewers, reviewers notify the Guardian, QA interrupts engineers for bugs. - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it ### Workflow When Delegating diff --git a/.claude/agents/qa-reviewer.md b/.claude/agents/qa-reviewer.md index 4fad8e6e8..13cbc93a0 100644 --- a/.claude/agents/qa-reviewer.md +++ b/.claude/agents/qa-reviewer.md @@ -55,7 +55,7 @@ Only the Guardian commits, stages, and completes [tasks]. Notify the Guardian if 10. **Requirements verification**. Return to your Phase 1 checklist. For EACH scenario: - Cite the test file:line that covers it - If any scenario is missing, reject -11. **Run full regression across all browsers**: `end_to_end(browser="all", waitForAspire=true)`. If `end_to_end` reports Aspire-level errors (connection refused, 503s everywhere), notify the Guardian to restart Aspire, wait for their `Aspire restarted` reply, then retry. If ANY test fails or is flaky (fails once, passes on re-run), reject. Ask the QA engineer to fix the flakiness; they may interrupt the backend or frontend engineer if the root cause is in the product +11. **Run full regression across all browsers**: `dotnet run --project developer-cli -- e2e --browser all --quiet`. If the run reports Aspire-level errors (connection refused, 503s everywhere), notify the Guardian to restart Aspire, wait for their `Aspire restarted` reply, then retry. If ANY test fails or is flaky (fails once, passes on re-run), reject. Ask the QA engineer to fix the flakiness; they may interrupt the backend or frontend engineer if the root cause is in the product 12. Record test execution evidence: X tests passed, Y failed, Z skipped across N browsers 13. **Send the Guardian one approval message** (only after every test passes reliably): @@ -131,4 +131,4 @@ If the [task] is not in [Active] when you start, stop and escalate. If blocked a - When the engineer pushes back with evidence, evaluate objectively - Escalate unresolvable disagreements to the team lead - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it diff --git a/.claude/agents/qa.md b/.claude/agents/qa.md index 7b438fd89..d9cd22897 100644 --- a/.claude/agents/qa.md +++ b/.claude/agents/qa.md @@ -74,9 +74,9 @@ When many tests fail at once (50+), the root cause is almost always a single sha ### After Implementing -Run only the test files you changed using the **end_to_end** MCP tool with `waitForAspire=true`. Zero tolerance for failures. Iterate until all changed tests pass. +Run only the test files you changed using the **e2e** skill (`--quiet`). Zero tolerance for failures. Iterate until all changed tests pass. -If `end_to_end` reports Aspire-level errors (connection refused, 503s everywhere, unhealthy containers), notify the Guardian to restart Aspire. Wait for the Guardian's `Aspire restarted` reply, then retry. +If the e2e run reports Aspire-level errors (connection refused, 503s everywhere, unhealthy containers), notify the Guardian to restart Aspire. Wait for the Guardian's `Aspire restarted` reply, then retry. The QA reviewer runs the full regression across all browsers before approving -- you do not need to run everything yourself. Exception: if the reviewer reports flaky tests, you may re-run the full suite to reproduce and diagnose before fixing. @@ -169,4 +169,4 @@ After the Guardian commits, call TaskList for your next assignment. Claim with T - Be specific: file paths, test names, pass/fail counts, concrete details - Only notify the team lead when blocked or done with all work - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it diff --git a/.claude/agents/regression-tester.md b/.claude/agents/regression-tester.md index 65524c192..b470142ce 100644 --- a/.claude/agents/regression-tester.md +++ b/.claude/agents/regression-tester.md @@ -29,7 +29,7 @@ You persist across the entire [feature]. You maintain context across all tasks. - Use `owner@platformplatform.local` / `admin@platformplatform.local` / `member@platformplatform.local` - The OTP is always `UNLOCK` on localhost - If unable to login with `owner@platformplatform.local`, create a new tenant and invite the other users -- Access the application at `https://app.dev.localhost:` (call the `get_ports` MCP tool to find the current `appGateway` port). +- Access the application at `https://app.dev.localhost:`. Look up the `appGateway` port via the Aspire MCP `list_resources` tool, or read `.workspace/port.txt` for the base port (the gateway runs on the base port itself). ## What to Test @@ -84,4 +84,4 @@ Before going idle, always notify the team lead with your current status. - SendMessage is the only way teammates see you. Your text output is invisible to them - Never send more than one message to the same agent without getting a response - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it diff --git a/.claude/agents/researcher.md b/.claude/agents/researcher.md index 21e45e214..60bf13dd6 100644 --- a/.claude/agents/researcher.md +++ b/.claude/agents/researcher.md @@ -50,4 +50,4 @@ When research is done, reply to the agent that asked with your findings, then go - Include code examples from documentation when relevant - Cite your sources with URLs - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it diff --git a/.claude/agents/team-lead.md b/.claude/agents/team-lead.md index afa6f98a2..6ef19537e 100644 --- a/.claude/agents/team-lead.md +++ b/.claude/agents/team-lead.md @@ -193,13 +193,13 @@ There are two channels: |-----------|--------| | Agent is idle/hibernated | SendMessage (wakes them up) | | Agent is working, message can wait | SendMessage (queued until their turn ends) | -| Agent is working, message is urgent | Interrupt (SendInterruptSignal + SendMessage) | +| Agent is working, message is urgent | Interrupt (use the **team-interrupt** skill, then SendMessage) | | Target is the Guardian | Always notify (SendMessage), never interrupt (exception: team lead may interrupt) | The Guardian can receive multiple SendMessages from different agents without responses in between -- it processes staging requests, restart requests, and commit requests as a queue. - **Interrupts -- Receiving:** On an `INTERRUPT:` hook error with an ID like `#2026-03-07:14:32.09`, stop and read incoming messages until you find the one starting with that ID -- **Interrupts -- Sending:** Interrupt = SendInterruptSignal + SendMessage (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it +- **Interrupts -- Sending:** Interrupt = use the **team-interrupt** skill (urgent). Notify = SendMessage only (can wait). Always notify the Guardian, never interrupt it ### Communication Flows @@ -208,8 +208,8 @@ The Guardian can receive multiple SendMessages from different agents without res **Correct unstarted work:** TaskUpdate the task description. No message needed. If unsure whether started, use urgent redirect. **Urgently redirect a busy agent:** -1. Call `SendInterruptSignal` MCP tool with your message. The tool returns an interrupt ID (e.g., `#2026-03-07:14:32.09`) -2. Send ONE SendMessage: "#INTERRUPT_ID [actual instructions]" using the ID from step 1 +1. Use the **team-interrupt** skill - it returns an interrupt ID (e.g., `#2026-03-07:14:32.09`) +2. Send ONE SendMessage prefixed with that ID: `# [actual instructions]` 3. STOP. No follow-ups The interrupt ID links the signal to the correct follow-up message. Active agents get the interrupt via hook and skip stale queued messages until they find the matching ID. Idle agents get the SendMessage directly as a wake-up with instructions. diff --git a/.claude/commands/prepare-pull-request.md b/.claude/commands/prepare-pull-request.md index 663569703..f318b3684 100644 --- a/.claude/commands/prepare-pull-request.md +++ b/.claude/commands/prepare-pull-request.md @@ -28,22 +28,22 @@ Use this workflow to create pull request titles and descriptions. - **Summary & Motivation**: Start with the most important change, use bullet points for multiple changes, and mention minor fixes last - **Checklist**: Do not change the checklist from .github/PULL_REQUEST_TEMPLATE.md, and do not [x] the list—this should be a manual task -5. Build, test, format and lint the codebase using the MCP tools: +5. Optionally build, test, format and lint the codebase using the developer CLI skills (skip if validation has already been done in this session): **For backend changes** (`*.cs` files): - 1. Run **build** first: `build(backend=true)` - 2. Then run **format**, **test**, **lint** in parallel (or sequentially if parallel not supported): - - `format(backend=true)` - - `test(backend=true)` - - `lint(backend=true)` + 1. Run **build** first: `dotnet run --project developer-cli -- build --backend --quiet` + 2. Then run **format**, **test**, **lint** in parallel with `--no-build`: + - `dotnet run --project developer-cli -- format --backend --no-build --quiet` + - `dotnet run --project developer-cli -- test --no-build --quiet` + - `dotnet run --project developer-cli -- lint --backend --no-build --quiet` **For frontend changes** (`*.ts`, `*.tsx` files): - 1. Run **build** first: `build(frontend=true)` - 2. Then run **format** and **lint** in parallel (or sequentially if parallel not supported): - - `format(frontend=true)` - - `lint(frontend=true)` + 1. Run **build** first: `dotnet run --project developer-cli -- build --frontend --quiet` + 2. Then run **format** and **lint** in parallel with `--no-build`: + - `dotnet run --project developer-cli -- format --frontend --no-build --quiet` + - `dotnet run --project developer-cli -- lint --frontend --no-build --quiet` - If there are errors, they must be fixed before the pull request can be created. + If you do run validation and find errors, they must be fixed before the pull request can be created. 6. Save the pull request title (as a level 1 heading) and description to `.workspace//pull-request.md` - End with a clickable link to the saved file using the full absolute path diff --git a/.claude/rules/ai-rules/ai-rules.md b/.claude/rules/ai-rules/ai-rules.md index 4120effb4..bb6eb4699 100644 --- a/.claude/rules/ai-rules/ai-rules.md +++ b/.claude/rules/ai-rules/ai-rules.md @@ -5,7 +5,7 @@ description: Guidelines for creating and updating AI rules and commands # AI Rules -Guidelines for creating, updating, and reviewing AI configuration files (rules, commands/workflows, etc.). The `.claude/` directory is the source of truth and should be synced to other AI editors via the `sync_ai_rules` MCP tool. +Guidelines for creating, updating, and reviewing AI configuration files (rules, commands/workflows, etc.). The `.claude/` directory is the source of truth and should be synced to other AI editors via `dotnet run --project developer-cli -- sync-ai-rules --quiet`. ## Directory Structure @@ -100,7 +100,7 @@ The `[PRODUCT_MANAGEMENT_TOOL]` variable in `AGENTS.md` determines which specifi 1. `.claude/` is the source of truth. Don't modify files in other editor directories directly -2. Run the `sync_ai_rules` MCP tool after updating files +2. Run `dotnet run --project developer-cli -- sync-ai-rules --quiet` after updating files ## Review Checklist @@ -112,7 +112,7 @@ When reviewing changes to rules or commands: - [ ] Examples use ✅/❌ patterns where applicable - [ ] File organization matches its category - [ ] Tool-agnostic terminology used (no Issue, Epic, Story, etc.) -- [ ] If syncing to other editors, `sync_ai_rules` MCP tool will be run after changes +- [ ] If syncing to other editors, `dotnet run --project developer-cli -- sync-ai-rules --quiet` will be run after changes ## Examples diff --git a/.claude/rules/backend/backend.md b/.claude/rules/backend/backend.md index e5b7b2174..688346ce7 100644 --- a/.claude/rules/backend/backend.md +++ b/.claude/rules/backend/backend.md @@ -109,13 +109,11 @@ Follow these steps when implementing changes: 1. Always start new changes by writing new test cases (or change existing tests)—consult [API Tests](/.claude/rules/backend/api-tests.md) for details 2. Build and test your changes: - - Use the **build** MCP tool for backend - - Use the **test** MCP tool to run all tests - - If you change API contracts (endpoints, DTOs), also build frontend to ensure it still compiles + - Use the **build** skill (`--backend --quiet`) + - Use the **test** skill to run all tests (`--quiet`) + - If you change API contracts (endpoints, DTOs), also build the frontend to ensure it still compiles 3. Format and lint your code in parallel: - - When all tests pass and the feature is complete, call both MCP tools in a single message: - - `format(backend=true)` - - `lint(backend=true)` + - When all tests pass and the feature is complete, run **format** and **lint** in parallel (`--backend --no-build --quiet` on both) - Format automatically fixes code style issues according to our conventions - **ALL lint findings are blocking** - CI pipeline fails on any result marked "Issues found" - Severity level (note/warning/error) is irrelevant - fix all findings before proceeding diff --git a/.claude/rules/end-to-end-tests/end-to-end-tests.md b/.claude/rules/end-to-end-tests/end-to-end-tests.md index 9a7494de4..7e89a4ed4 100644 --- a/.claude/rules/end-to-end-tests/end-to-end-tests.md +++ b/.claude/rules/end-to-end-tests/end-to-end-tests.md @@ -9,19 +9,19 @@ These rules outline the structure, patterns, and best practices for writing end- ## Implementation -1. Use the **end-to-end MCP tool** to run end-to-end tests with these options: +1. Use the **e2e** skill to run end-to-end tests with these options: - Test filtering: smoke tests only, specific browser, search terms - Change scoping: last failed tests, only changed tests - - Flaky test detection: repeat tests, retry on failure, stop on first failure + - Flaky test detection: retry on failure, stop on first failure - Performance: debug timing to see step execution times - - **Note**: The **end-to-end MCP tool** always runs with quiet mode automatically + - Always pass `--quiet` 2. Test Search and Filtering: - Search by test tags: smoke, comprehensive - Search by test content: find tests containing specific text - Search by filename: find specific test files - - Multiple search terms: `end-to-end(searchTerms=["user", "management"])` - - The tool automatically detects which self-contained systems contain matching tests and only runs those + - Multiple search terms: `dotnet run --project developer-cli -- e2e "user" "management" --quiet` + - The CLI automatically detects which self-contained systems contain matching tests and only runs those 3. Test-Driven Debugging Process: - Focus on one failing test at a time and make it pass before moving to the next diff --git a/.claude/rules/frontend/frontend.md b/.claude/rules/frontend/frontend.md index 233c5e133..dd305d557 100644 --- a/.claude/rules/frontend/frontend.md +++ b/.claude/rules/frontend/frontend.md @@ -13,7 +13,7 @@ Use LSP tools aggressively for code investigation: `goToDefinition`, `findRefere ## Browser Testing -Use browser MCP tools to test at `https://app.dev.localhost:` (call the `get_ports` MCP tool to find the current `appGateway` port). Use `UNLOCK` as OTP verification code (localhost only). +Use browser MCP tools to test at `https://app.dev.localhost:`. Look up the `appGateway` port via the Aspire MCP `list_resources` tool, or read `.workspace/port.txt` for the base port (the gateway runs on the base port itself). Use `UNLOCK` as OTP verification code (localhost only). ## Architecture Overview @@ -147,11 +147,11 @@ Use browser MCP tools to test at `https://app.dev.localhost:` (call - Reference existing implementations to maintain consistency 11. Build and format your changes: - - After each minor change, use the **build** MCP tool for frontend + - After each minor change, use the **build** skill (`--frontend --quiet`) - This ensures consistent code style across the codebase 12. Verify your changes: - - When a feature is complete, run these MCP tools for frontend in sequence: **build**, **format**, **lint** + - When a feature is complete, run **build**, then **format** and **lint** in parallel (with `--no-build`), all scoped with `--frontend --quiet` - **ALL lint findings are blocking** - CI pipeline fails on any result marked "Issues found" - Severity level (note/warning/error) is irrelevant - fix all findings before proceeding - Fix any compiler warnings or test failures before proceeding diff --git a/.claude/skills/fix-e2e-tests/SKILL.md b/.claude/skills/fix-e2e-tests/SKILL.md index 02e3ed23b..fac363b9a 100644 --- a/.claude/skills/fix-e2e-tests/SKILL.md +++ b/.claude/skills/fix-e2e-tests/SKILL.md @@ -1,7 +1,7 @@ --- name: fix-e2e-tests description: Systematically fix all failing E2E tests using a phased diagnostic approach. Classifies tests as passing, flaky, or permanently failing, then fixes them one by one with progressive scope expansion. -allowed-tools: Read, Write, Edit, Bash, Glob, Grep, mcp__developer-cli__end_to_end, mcp__developer-cli__build, mcp__developer-cli__run +allowed-tools: Read, Write, Edit, Bash, Glob, Grep --- # Fix E2E Tests From b840e35c30c8df9e4878c362863bbcd6a58fed00 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 28 Apr 2026 15:11:44 +0200 Subject: [PATCH 5/9] Delete developer-cli MCP server, send-interrupt bash hook, and ModelContextProtocol package references --- .claude/hooks/send-interrupt.sh | 29 - .claude/settings.json | 4 +- .mcp.json | 4 - developer-cli/Commands/McpCommand.cs | 891 --------------------------- developer-cli/DeveloperCli.csproj | 2 - 5 files changed, 1 insertion(+), 929 deletions(-) delete mode 100755 .claude/hooks/send-interrupt.sh delete mode 100644 developer-cli/Commands/McpCommand.cs diff --git a/.claude/hooks/send-interrupt.sh b/.claude/hooks/send-interrupt.sh deleted file mode 100755 index 5e69e6e57..000000000 --- a/.claude/hooks/send-interrupt.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash - -# Helper script to send an interrupt signal to a team agent. -# -# Usage: -# send-interrupt.sh -# -# Examples: -# send-interrupt.sh feature-team backend "STOP: Wrong file, use Api/Program.cs" -# send-interrupt.sh feature-team frontend "API contract changed, re-read spec" - -TEAM_NAME="$1" -AGENT_NAME="$2" -MESSAGE="$3" - -if [ -z "$TEAM_NAME" ] || [ -z "$AGENT_NAME" ] || [ -z "$MESSAGE" ]; then - echo "Usage: send-interrupt.sh " >&2 - exit 1 -fi - -SIGNALS_DIR="$HOME/.claude/teams/${TEAM_NAME}/signals" - -if [ ! -d "$SIGNALS_DIR" ]; then - mkdir -p "$SIGNALS_DIR" -fi - -SIGNAL_FILE="${SIGNALS_DIR}/${AGENT_NAME}.signal" -echo "$MESSAGE" > "$SIGNAL_FILE" -echo "Interrupt sent to ${AGENT_NAME} in ${TEAM_NAME}" diff --git a/.claude/settings.json b/.claude/settings.json index 1d67a33b5..ab137f743 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,5 @@ { - "enabledMcpjsonServers": [ - "developer-cli" - ], + "enabledMcpjsonServers": [], "hooks": { "PreToolUse": [ { diff --git a/.mcp.json b/.mcp.json index fb05bd9a1..06aca5184 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,9 +1,5 @@ { "mcpServers": { - "developer-cli": { - "command": "dotnet", - "args": ["run", "--project", "developer-cli", "mcp"] - }, "shadcn": { "command": "npx", "args": ["-y", "shadcn@latest", "mcp"] diff --git a/developer-cli/Commands/McpCommand.cs b/developer-cli/Commands/McpCommand.cs deleted file mode 100644 index 467cd85d2..000000000 --- a/developer-cli/Commands/McpCommand.cs +++ /dev/null @@ -1,891 +0,0 @@ -using System.CommandLine; -using System.ComponentModel; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; -using DeveloperCli.Installation; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using ModelContextProtocol; -using ModelContextProtocol.Server; - -namespace DeveloperCli.Commands; - -public class McpCommand : Command -{ - public McpCommand() : base("mcp", "Start MCP server for AI integration") - { - SetAction(async _ => await ExecuteAsync()); - } - - private static async Task ExecuteAsync() - { - McpDebugLog.Initialize(); - McpDebugLog.Log("MCP server process starting"); - McpDebugLog.Log($"Process ID: {Environment.ProcessId}"); - McpDebugLog.Log($"Working directory: {Environment.CurrentDirectory}"); - McpDebugLog.Log($"CLI source: {Configuration.SourceCodeFolder}"); - McpDebugLog.Log($"Runtime: {RuntimeInformation.FrameworkDescription}, OS: {RuntimeInformation.OSDescription}"); - McpDebugLog.Log($"Args: {string.Join(" ", Environment.GetCommandLineArgs())}"); - McpDebugLog.LogGitContext(); - McpDebugLog.DetectPreviousCrash(); - McpDebugLog.WriteSessionPid(); - - AppDomain.CurrentDomain.UnhandledException += (_, args) => { McpDebugLog.Log($"UNHANDLED EXCEPTION (terminating={args.IsTerminating}): {args.ExceptionObject}"); }; - - TaskScheduler.UnobservedTaskException += (_, args) => - { - McpDebugLog.Log($"UNOBSERVED TASK EXCEPTION: {args.Exception}"); - args.SetObserved(); - }; - - AppDomain.CurrentDomain.ProcessExit += (_, _) => - { - McpDebugLog.Log("ProcessExit event fired -- MCP server shutting down"); - McpDebugLog.ClearSessionPid(); - }; - - Console.CancelKeyPress += (_, args) => - { - McpDebugLog.Log($"CancelKeyPress received (SpecialKey={args.SpecialKey})"); - args.Cancel = true; // Let the host shut down gracefully - }; - - RegisterPosixSignalHandlers(); - - try - { - try - { - await Console.Error.WriteLineAsync("[MCP] Starting MCP server..."); - await Console.Error.WriteLineAsync("[MCP] Listening on stdio for MCP communication"); - } - catch (IOException) - { - McpDebugLog.Log("WARNING: Failed to write startup message to stderr (stream closed)"); - } - - var builder = Host.CreateApplicationBuilder(); - builder.Logging.AddConsole(consoleLogOptions => { consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; }); - - // Allow 5 minutes for in-flight tools (like EndToEnd) to complete during shutdown. - builder.Services.Configure(options => options.ShutdownTimeout = TimeSpan.FromMinutes(5)); - - builder.Services - .AddMcpServer() - .WithStdioServerTransport() - .WithToolsFromAssembly(); - - // Replace the SDK's SingleSessionMcpServerHostedService (which calls StopApplication on stdin EOF) - // with our own resilient version that keeps the process alive. - ReplaceSingleSessionHostedService(builder.Services); - - var host = builder.Build(); - McpDebugLog.Log("MCP host built successfully, starting RunAsync"); - - await host.RunAsync(); - - McpDebugLog.Log("MCP host RunAsync completed normally"); - } - catch (OperationCanceledException) - { - McpDebugLog.Log("MCP server stopped via cancellation (normal shutdown)"); - } - catch (Exception exception) - { - McpDebugLog.Log($"FATAL EXCEPTION in MCP server: {exception}"); - throw; - } - finally - { - McpDebugLog.Log("MCP server ExecuteAsync exiting"); - } - } - - private static void RegisterPosixSignalHandlers() - { - // SIGTERM and SIGHUP are not available on Windows - PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => - { - McpDebugLog.Log("SIGTERM received -- process is being terminated"); - context.Cancel = true; - } - ); - - PosixSignalRegistration.Create(PosixSignal.SIGINT, context => - { - McpDebugLog.Log("SIGINT received -- process interrupted"); - context.Cancel = true; - } - ); - - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - PosixSignalRegistration.Create(PosixSignal.SIGHUP, context => - { - McpDebugLog.Log("SIGHUP received -- terminal disconnected"); - context.Cancel = true; - } - ); - } - } - - private static void ReplaceSingleSessionHostedService(IServiceCollection services) - { - // Remove the SDK's SingleSessionMcpServerHostedService that calls StopApplication() on stdin EOF. - var singleSessionDescriptor = services.FirstOrDefault(descriptor => - descriptor.ServiceType == typeof(IHostedService) && descriptor.ImplementationType?.Name == "SingleSessionMcpServerHostedService" - ); - - if (singleSessionDescriptor is not null) - { - services.Remove(singleSessionDescriptor); - McpDebugLog.Log("Removed SDK's SingleSessionMcpServerHostedService"); - } - else - { - McpDebugLog.Log("WARNING: Could not find SingleSessionMcpServerHostedService to remove"); - } - - services.AddHostedService(); - } -} - -/// -/// Replaces the SDK's SingleSessionMcpServerHostedService. When the MCP session ends (stdin EOF), -/// this service waits for in-flight tool executions to complete instead of immediately calling -/// StopApplication(). This prevents the process from being killed mid-tool-execution when the -/// client disconnects. -/// -public sealed class ResilientMcpServerHostedService(McpServer session, IHostApplicationLifetime lifetime) : BackgroundService -{ - private static readonly TimeSpan GracePeriod = TimeSpan.FromMinutes(5); - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try - { - McpDebugLog.Log("ResilientMcpServerHostedService: session starting"); - await session.RunAsync(stoppingToken); - McpDebugLog.Log("ResilientMcpServerHostedService: session ended (stdin closed)"); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - McpDebugLog.Log("ResilientMcpServerHostedService: session cancelled by host shutdown"); - return; - } - catch (Exception exception) - { - McpDebugLog.Log($"ResilientMcpServerHostedService: session failed -- {exception}"); - } - - // Session ended (stdin EOF). Wait for any in-flight tool executions to finish - // before shutting down the process. Tools acquire ToolExecutionSemaphore, so we - // try to acquire it here -- if we get it, no tools are running. - McpDebugLog.Log($"ResilientMcpServerHostedService: waiting up to {GracePeriod.TotalSeconds}s for in-flight tools"); - var acquired = await DeveloperCliMcpTools.ToolExecutionSemaphore.WaitAsync(GracePeriod, CancellationToken.None); - if (acquired) - { - DeveloperCliMcpTools.ToolExecutionSemaphore.Release(); - McpDebugLog.Log("ResilientMcpServerHostedService: no in-flight tools, shutting down"); - } - else - { - McpDebugLog.Log("ResilientMcpServerHostedService: grace period expired, shutting down with tools still running"); - } - - lifetime.StopApplication(); - } -} - -public static class McpDebugLog -{ - private const long MaxLogFileSize = 512 * 1024; // 500 KB - private static string? _logFilePath; - private static string? _sessionPidFilePath; - private static readonly Lock Lock = new(); - - public static void Initialize() - { - var logsDirectory = Path.Combine(Configuration.WorkspaceFolder, "developer-cli", "mcp"); - - if (!Directory.Exists(logsDirectory)) - { - Directory.CreateDirectory(logsDirectory); - } - - _logFilePath = Path.Combine(logsDirectory, "mcp-debug.log"); - _sessionPidFilePath = Path.Combine(logsDirectory, "mcp-session.pid"); - - RotateLogIfNeeded(); - - Log("=== MCP Debug Log Initialized ==="); - } - - public static void Log(string message) - { - var logFilePath = _logFilePath; - if (logFilePath is null) return; - - var timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff"); - var threadId = Environment.CurrentManagedThreadId; - var entry = $"[{timestamp}] [T{threadId}] [PID:{Environment.ProcessId}] {message}\n"; - - try - { - lock (Lock) - { - File.AppendAllText(logFilePath, entry); - } - } - catch - { - // Cannot fail -- this is the last resort logger - } - } - - public static void LogGitContext() - { - try - { - var branch = RunGitCommand("branch --show-current"); - var commit = RunGitCommand("rev-parse --short HEAD"); - Log($"Git: branch={branch}, commit={commit}"); - } - catch (Exception exception) - { - Log($"Git context unavailable: {exception.Message}"); - } - } - - public static void DetectPreviousCrash() - { - if (_sessionPidFilePath is null || !File.Exists(_sessionPidFilePath)) return; - - try - { - var previousPidText = File.ReadAllText(_sessionPidFilePath).Trim(); - if (!int.TryParse(previousPidText, out var previousPid)) return; - - var isStillRunning = false; - try - { - using var process = Process.GetProcessById(previousPid); - isStillRunning = !process.HasExited; - } - catch (ArgumentException) - { - // Process does not exist -- it crashed or was killed - } - catch (InvalidOperationException) - { - // Process has exited between GetProcessById and HasExited check - } - - if (!isStillRunning) - { - Log($"CRASH DETECTED: previous session PID {previousPid} is no longer running (no clean shutdown logged)"); - } - } - catch (Exception exception) - { - Log($"WARNING: Failed to check previous session: {exception.Message}"); - } - } - - public static void WriteSessionPid() - { - if (_sessionPidFilePath is null) return; - - try - { - File.WriteAllText(_sessionPidFilePath, Environment.ProcessId.ToString()); - } - catch (Exception exception) - { - Log($"WARNING: Failed to write session PID file: {exception.Message}"); - } - } - - public static void ClearSessionPid() - { - if (_sessionPidFilePath is null) return; - - try - { - if (File.Exists(_sessionPidFilePath)) - { - File.Delete(_sessionPidFilePath); - } - } - catch - { - // Best effort -- process is exiting - } - } - - private static void RotateLogIfNeeded() - { - var logFilePath = _logFilePath; - if (logFilePath is null) return; - - try - { - if (!File.Exists(logFilePath)) return; - - var fileInfo = new FileInfo(logFilePath); - if (fileInfo.Length <= MaxLogFileSize) return; - - var previousLogFilePath = logFilePath + ".prev"; - if (File.Exists(previousLogFilePath)) - { - File.Delete(previousLogFilePath); - } - - File.Move(logFilePath, previousLogFilePath); - } - catch - { - // Best effort -- don't block startup over log rotation - } - } - - private static string RunGitCommand(string arguments) - { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = "git", - Arguments = arguments, - WorkingDirectory = Configuration.SourceCodeFolder, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - process.Start(); - var output = process.StandardOutput.ReadToEnd().Trim(); - process.WaitForExit(TimeSpan.FromSeconds(5)); - return output; - } -} - -[McpServerToolType] -public static partial class DeveloperCliMcpTools -{ - private static readonly TimeSpan ProgressInterval = TimeSpan.FromSeconds(5); - - // Serialize tool execution to prevent concurrent tool calls from crashing the stdio transport. - // When Claude Code sends multiple tool calls simultaneously and the first completes, the client - // closes stdin before the second finishes, killing the MCP server process. - // Internal so ResilientMcpServerHostedService can check for in-flight tools before shutdown. - internal static readonly SemaphoreSlim ToolExecutionSemaphore = new(1, 1); - - [McpServerTool] - [Description("Build the solution including backend (.NET), frontend (React/TypeScript), and developer CLI projects")] - public static async Task Build( - IProgress progress, - CancellationToken cancellationToken, - [Description("Backend (.NET)")] bool backend = false, - [Description("Frontend (React/TypeScript)")] - bool frontend = false, - [Description("Developer CLI")] bool cli = false, - [Description("Self-contained system, e.g., 'account' (optional)")] - string? selfContainedSystem = null) - { - var args = new List { "build", "--quiet" }; - - if (backend) args.Add("--backend"); - if (frontend) args.Add("--frontend"); - if (cli) args.Add("--cli"); - - if (selfContainedSystem is not null) - { - args.Add("--self-contained-system"); - args.Add(selfContainedSystem); - } - - return await ExecuteCliCommandAsync(progress, "Build", args.ToArray(), cancellationToken); - } - - [McpServerTool] - [Description("Run backend (.NET) xUnit tests with optional name filtering")] - public static async Task Test( - IProgress progress, - CancellationToken cancellationToken, - [Description("Backend (.NET)")] bool backend = false, - [Description("Skip build before running tests")] - bool noBuild = false, - [Description("Filter tests by name")] string? filter = null, - [Description("Self-contained system, e.g., 'account' (optional)")] - string? selfContainedSystem = null) - { - var args = new List { "test", "--quiet" }; - - if (backend) args.Add("--backend"); - - if (selfContainedSystem is not null) - { - args.Add("--self-contained-system"); - args.Add(selfContainedSystem); - } - - if (noBuild) args.Add("--no-build"); - - if (filter is not null) - { - args.Add("--filter"); - args.Add(filter); - } - - return await ExecuteCliCommandAsync(progress, "Test", args.ToArray(), cancellationToken); - } - - [McpServerTool] - [Description("Format and auto-fix code style for backend (.NET), frontend (React/TypeScript), and developer CLI projects")] - public static async Task Format( - IProgress progress, - CancellationToken cancellationToken, - [Description("Backend (.NET)")] bool backend = false, - [Description("Frontend (React/TypeScript)")] - bool frontend = false, - [Description("Developer CLI")] bool cli = false, - [Description("Skip build before formatting")] - bool noBuild = false, - [Description("Self-contained system, e.g., 'account' (optional)")] - string? selfContainedSystem = null) - { - var args = new List { "format", "--quiet" }; - - if (backend) args.Add("--backend"); - if (frontend) args.Add("--frontend"); - if (cli) args.Add("--cli"); - - if (selfContainedSystem is not null) - { - args.Add("--self-contained-system"); - args.Add(selfContainedSystem); - } - - if (noBuild) args.Add("--no-build"); - - return await ExecuteCliCommandAsync(progress, "Format", args.ToArray(), cancellationToken); - } - - [McpServerTool] - [Description("Lint code for backend (.NET), frontend (React/TypeScript), and developer CLI projects to find issues")] - public static async Task Lint( - IProgress progress, - CancellationToken cancellationToken, - [Description("Backend (.NET)")] bool backend = false, - [Description("Frontend (React/TypeScript)")] - bool frontend = false, - [Description("Developer CLI")] bool cli = false, - [Description("Skip build before linting")] - bool noBuild = false, - [Description("Self-contained system, e.g., 'account' (optional)")] - string? selfContainedSystem = null) - { - var args = new List { "lint", "--quiet" }; - - if (backend) args.Add("--backend"); - if (frontend) args.Add("--frontend"); - if (cli) args.Add("--cli"); - - if (selfContainedSystem is not null) - { - args.Add("--self-contained-system"); - args.Add(selfContainedSystem); - } - - if (noBuild) args.Add("--no-build"); - - return await ExecuteCliCommandAsync(progress, "Lint", args.ToArray(), cancellationToken); - } - - [McpServerTool] - [Description("Start .NET Aspire AppHost. By default uses the base port from .workspace/port.txt. Pass basePort to override (writes the new value to .workspace/port.txt) -- useful for running parallel stacks from different git worktrees. Call get_ports to discover the actual URL. Fails if already running -- use Restart to replace a running instance, or Stop to stop it.")] - public static Task Run( - [Description("Optional base port. When set, overwrites .workspace/port.txt. Leave unset to use the existing base port.")] - int? basePort = null) - { - var gatewayPort = basePort ?? RunCommand.Ports.AppGateway; - return ExecuteAspireLifecycleCommand("run", "Run", $"Aspire is starting on https://app.dev.localhost:{gatewayPort}", basePort); - } - - [McpServerTool] - [Description("Stop any running Aspire AppHost and start a fresh instance. Pass basePort to override the base port (writes the new value to .workspace/port.txt). Use after backend changes or when hot reload breaks.")] - public static Task Restart( - [Description("Optional base port. When set, overwrites .workspace/port.txt. Leave unset to use the existing base port.")] - int? basePort = null) - { - var gatewayPort = basePort ?? RunCommand.Ports.AppGateway; - return ExecuteAspireLifecycleCommand("restart", "Restart", $"Aspire is restarting on https://app.dev.localhost:{gatewayPort}", basePort); - } - - [McpServerTool] - [Description("Stop the running Aspire AppHost.")] - public static Task Stop() - { - return ExecuteAspireLifecycleCommand("stop", "Stop", "Aspire stopped successfully"); - } - - [McpServerTool] - [Description("Get the local development port allocation as JSON. Use this to discover the actual ports for any local URLs (AppGateway, Aspire dashboard, individual APIs, static dev servers, etc.).")] - public static string GetPorts() - { - var ports = RunCommand.Ports; - return JsonSerializer.Serialize(new - { - basePort = ports.BasePort, - appGateway = ports.AppGateway, - aspire = ports.Aspire, - postgres = ports.Postgres, - blob = ports.Blob, - mailpitSmtp = ports.MailpitSmtp, - mailpitHttp = ports.MailpitHttp, - otelEndpoint = ports.OtelEndpoint, - resourceService = ports.ResourceService, - mainApi = ports.MainApi, - mainStatic = ports.MainStatic, - mainWorkers = ports.MainWorkers, - accountApi = ports.AccountApi, - accountStatic = ports.AccountStatic, - accountWorkers = ports.AccountWorkers, - backOfficeApi = ports.BackOfficeApi, - backOfficeStatic = ports.BackOfficeStatic, - backOfficeWorkers = ports.BackOfficeWorkers - }, new JsonSerializerOptions { WriteIndented = true } - ); - } - - private static async Task ExecuteAspireLifecycleCommand(string cliCommand, string toolName, string successMessage, int? basePort = null) - { - McpDebugLog.Log($"TOOL INVOKED: {toolName} (basePort={(basePort.HasValue ? basePort.Value.ToString() : "default")})"); - var stopwatch = Stopwatch.StartNew(); - - try - { - var developerCliPath = Path.Combine(Configuration.SourceCodeFolder, "developer-cli"); - var args = new List { "run", "--project", developerCliPath, "--", cliCommand }; - if (basePort.HasValue) args.Add(basePort.Value.ToString()); - - var processStartInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = string.Join(" ", args), - WorkingDirectory = Configuration.SourceCodeFolder, - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using var process = new Process(); - process.StartInfo = processStartInfo; - var output = new List(); - var errors = new List(); - - process.OutputDataReceived += (_, e) => - { - try - { - if (e.Data is not null) output.Add(e.Data); - } - catch (Exception exception) - { - McpDebugLog.Log($"{toolName}: error in OutputDataReceived -- {exception.Message}"); - } - }; - process.ErrorDataReceived += (_, e) => - { - try - { - if (e.Data is not null) errors.Add(e.Data); - } - catch (Exception exception) - { - McpDebugLog.Log($"{toolName}: error in ErrorDataReceived -- {exception.Message}"); - } - }; - - process.Start(); - McpDebugLog.Log($"{toolName}: spawned process PID={process.Id}"); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - // Wait briefly to capture startup messages, then return (don't wait for full exit) - await Task.Delay(TimeSpan.FromSeconds(3)); - - if (process.HasExited && process.ExitCode != 0) - { - var result = $"Failed: {toolName}.\n\n{string.Join("\n", output)}\n{string.Join("\n", errors)}"; - McpDebugLog.Log($"TOOL FAILED: {toolName} after {stopwatch.ElapsedMilliseconds}ms -- {result}"); - return result; - } - - McpDebugLog.Log($"TOOL COMPLETED: {toolName} after {stopwatch.ElapsedMilliseconds}ms -- {successMessage}"); - return successMessage; - } - catch (Exception exception) - { - McpDebugLog.Log($"TOOL EXCEPTION: {toolName} after {stopwatch.ElapsedMilliseconds}ms -- {exception}"); - return $"Error during {toolName}: {exception.Message}"; - } - } - - [McpServerTool] - [Description("Run end-to-end tests")] - public static async Task EndToEnd( - IProgress progress, - CancellationToken cancellationToken, - [Description("Search terms")] string[]? searchTerms = null, - [Description("Browser (chromium, firefox, webkit, safari, all). Defaults to chromium")] - string browser = "chromium", - [Description("Smoke only")] bool smoke = false, - [Description("Skip waiting for Aspire to start (by default, retries server check up to 3 minutes)")] - bool noWaitForAspire = false, - [Description("Maximum retry count for flaky tests, zero for no retries")] - int? retries = null, - [Description("Stop after the first failure")] - bool stopOnFirstFailure = false, - [Description("Number of times to repeat each test")] - int? repeatEach = null, - [Description("Only re-run the failures")] - bool lastFailed = false, - [Description("Number of worker processes to use for running tests")] - int? workers = null) - { - var args = new List { "e2e", "--quiet" }; - if (searchTerms is { Length: > 0 }) args.AddRange(searchTerms); - args.Add("--browser"); - args.Add(browser); - - if (smoke) args.Add("--smoke"); - if (noWaitForAspire) args.Add("--no-wait-for-aspire"); - if (retries.HasValue) args.Add($"--retries={retries.Value}"); - if (stopOnFirstFailure) args.Add("--stop-on-first-failure"); - if (repeatEach.HasValue) args.Add($"--repeat-each={repeatEach.Value}"); - if (lastFailed) args.Add("--last-failed"); - if (workers.HasValue) args.Add($"--workers={workers.Value}"); - - return await ExecuteCliCommandAsync(progress, "EndToEnd", args.ToArray(), cancellationToken); - } - - [McpServerTool] - [Description("Send an interrupt signal to a team agent. The signal is picked up by the agent's PostToolUse hook on their next tool call. Returns an interrupt ID to use in the follow-up SendMessage. For idle/sleeping agents, also send a SendMessage to wake them.")] - public static string SendInterruptSignal( - [Description("Team name (e.g., 'feature-team')")] - string teamName, - [Description("Target agent name (e.g., 'backend', 'frontend')")] - string agentName) - { - McpDebugLog.Log($"TOOL INVOKED: SendInterruptSignal teamName={teamName}, agentName={agentName}"); - - try - { - var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var signalsDirectory = Path.Combine(homeDirectory, ".claude", "teams", teamName, "signals"); - - if (!Directory.Exists(signalsDirectory)) - { - Directory.CreateDirectory(signalsDirectory); - } - - var interruptId = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd:HH:mm.ss"); - var signalFilePath = Path.Combine(signalsDirectory, $"{agentName}.signal"); - File.WriteAllText(signalFilePath, $"Stop working until you see message #{interruptId}"); - - McpDebugLog.Log("TOOL COMPLETED: SendInterruptSignal"); - return $"Interrupt signal sent to {agentName}. Send your message now using SendMessage with prefix #{interruptId}"; - } - catch (Exception exception) - { - McpDebugLog.Log($"TOOL EXCEPTION: SendInterruptSignal -- {exception}"); - return $"Error sending interrupt signal: {exception.Message}"; - } - } - - [McpServerTool] - [Description("Sync AI rules from .claude to .windsurf and .cursor. CRITICAL: Always make AI rule changes in .claude folder first, then sync")] - public static async Task SyncAiRules(IProgress progress, CancellationToken cancellationToken) - { - return await ExecuteCliCommandAsync(progress, "SyncAiRules", ["sync-ai-rules"], cancellationToken); - } - - private static async Task ExecuteCliCommandAsync(IProgress progress, string toolName, string[] args, CancellationToken cancellationToken) - { - McpDebugLog.Log($"TOOL QUEUED: {toolName} -- args: [{string.Join(" ", args)}]"); - - await ToolExecutionSemaphore.WaitAsync(cancellationToken); - try - { - McpDebugLog.Log($"TOOL STARTED: {toolName} -- semaphore acquired"); - var stopwatch = Stopwatch.StartNew(); - - var developerCliPath = Path.Combine(Configuration.SourceCodeFolder, "developer-cli"); - var allArgs = new List { "run", "--project", developerCliPath, "--" }; - allArgs.AddRange(args); - - var command = $"dotnet {string.Join(" ", allArgs.Select(arg => arg.Contains(' ') ? $"\"{arg}\"" : arg))}"; - McpDebugLog.Log($"TOOL EXECUTING: {toolName} -- command: {command}"); - - var processStartInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = string.Join(" ", allArgs.Select(arg => arg.Contains(' ') ? $"\"{arg}\"" : arg)), - WorkingDirectory = Configuration.SourceCodeFolder, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(processStartInfo)!; - - // Do NOT register cancellationToken to kill the child process. The SDK's CancellationToken - // fires when the client sends notifications/cancelled OR when stdin closes. In both cases, - // we want the child process to finish its work rather than being killed mid-execution. - // The ResilientMcpServerHostedService waits for in-flight tools before stopping the process. - if (cancellationToken.IsCancellationRequested) - { - McpDebugLog.Log($"TOOL CANCELLED BEFORE START: {toolName} -- cancellation already requested"); - } - - var stdout = new StringBuilder(); - var stderr = new StringBuilder(); - var progressCounter = 0; - - var stdoutTask = ReadStreamWithProgressAsync(process.StandardOutput, stdout, progress, toolName, stopwatch, () => progressCounter++); - var stderrTask = ReadStreamAsync(process.StandardError, stderr); - - await Task.WhenAll(stdoutTask, stderrTask); - // Pass CancellationToken.None -- let the process finish even if the MCP client cancelled - await process.WaitForExitAsync(CancellationToken.None); - - var combinedOutput = $"{stdout}\n{stderr}"; - var outputLength = combinedOutput.Length; - var truncatedOutput = outputLength > 500 ? combinedOutput[..500] + "..." : combinedOutput; - McpDebugLog.Log($"TOOL COMPLETED: {toolName} after {stopwatch.ElapsedMilliseconds}ms -- exitCode={process.ExitCode}, outputLength={outputLength}, output: {truncatedOutput}"); - - const int maxOutputLength = 50_000; - if (combinedOutput.Length > maxOutputLength) - { - var half = maxOutputLength / 2; - combinedOutput = $"{combinedOutput[..half]}\n\n... [truncated {combinedOutput.Length - maxOutputLength} characters] ...\n\n{combinedOutput[^half..]}"; - } - - return combinedOutput; - } - catch (OperationCanceledException) - { - McpDebugLog.Log($"TOOL CANCELLED: {toolName} -- cancelled while waiting for semaphore"); - return $"Tool {toolName} was cancelled while queued."; - } - catch (Exception exception) - { - McpDebugLog.Log($"TOOL EXCEPTION: {toolName} -- {exception}"); - return $"Error executing command: {exception.Message}"; - } - finally - { - ToolExecutionSemaphore.Release(); - McpDebugLog.Log($"TOOL RELEASED: {toolName} -- semaphore released"); - } - } - - private static async Task ReadStreamWithProgressAsync(StreamReader reader, StringBuilder output, IProgress progress, string toolName, Stopwatch stopwatch, Func nextCounter) - { - var lastProgressTime = stopwatch.Elapsed; - var totalTests = 0; - var completedTests = 0; - - try - { - while (await reader.ReadLineAsync() is { } line) - { - output.AppendLine(line); - - // Strip ANSI escape codes for pattern matching - var cleanLine = AnsiEscapeRegex().Replace(line, ""); - - // Parse "Running N tests using M workers" to get total - if (totalTests == 0 && cleanLine.StartsWith("Running ") && cleanLine.Contains(" tests using ")) - { - var parts = cleanLine.Split(' '); - if (parts.Length >= 2 && int.TryParse(parts[1], out var parsed)) - { - totalTests = parsed; - McpDebugLog.Log($"PROGRESS parsed total tests: {totalTests} for {toolName}"); - } - } - - // Count completed tests (lines starting with check mark or cross) - var trimmed = cleanLine.TrimStart(); - if (totalTests > 0 && trimmed.Length > 0 && (trimmed[0] == '✓' || trimmed[0] == '✘' || trimmed.StartsWith("- ✓") || trimmed.StartsWith("- ✘"))) - { - completedTests++; - } - - var now = stopwatch.Elapsed; - if (now - lastProgressTime >= ProgressInterval) - { - lastProgressTime = now; - var counter = nextCounter(); - try - { - if (totalTests > 0) - { - progress.Report(new ProgressNotificationValue { Progress = completedTests, Total = totalTests }); - McpDebugLog.Log($"PROGRESS sent for {toolName} at {now.Minutes}m {now.Seconds}s ({completedTests}/{totalTests} tests)"); - } - else - { - progress.Report(new ProgressNotificationValue { Progress = counter, Total = counter + 10 }); - McpDebugLog.Log($"PROGRESS sent for {toolName} at {now.Minutes}m {now.Seconds}s (counter={counter}, no total yet)"); - } - } - catch (Exception exception) - { - McpDebugLog.Log($"PROGRESS failed for {toolName}: {exception.Message}"); - } - } - } - } - catch (IOException exception) - { - McpDebugLog.Log($"STREAM ERROR reading stdout for {toolName}: {exception.Message}"); - } - catch (ObjectDisposedException exception) - { - McpDebugLog.Log($"STREAM DISPOSED reading stdout for {toolName}: {exception.Message}"); - } - } - - [GeneratedRegex(@"\x1B\[[0-9;]*m")] - private static partial Regex AnsiEscapeRegex(); - - private static async Task ReadStreamAsync(StreamReader reader, StringBuilder output) - { - try - { - while (await reader.ReadLineAsync() is { } line) - { - output.AppendLine(line); - } - } - catch (IOException) - { - // Stream closed unexpectedly -- child process may have been killed - } - catch (ObjectDisposedException) - { - // Stream disposed -- process already exited - } - } -} diff --git a/developer-cli/DeveloperCli.csproj b/developer-cli/DeveloperCli.csproj index 038a9c747..820963825 100644 --- a/developer-cli/DeveloperCli.csproj +++ b/developer-cli/DeveloperCli.csproj @@ -20,8 +20,6 @@ - - From 45b7755eea328858f11e80af26640659ecac714a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 28 Apr 2026 15:15:12 +0200 Subject: [PATCH 6/9] Enable aspire and shadcn MCP servers in project settings --- .claude/settings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index ab137f743..5dba5f46c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,8 @@ { - "enabledMcpjsonServers": [], + "enabledMcpjsonServers": [ + "aspire", + "shadcn" + ], "hooks": { "PreToolUse": [ { From a2f79065b97390c00489b2de03c2b522ae7cb188 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 28 Apr 2026 16:00:37 +0200 Subject: [PATCH 7/9] Auto-pick base port for worktrees and drop CLI base port argument --- .claude/skills/aspire-restart/SKILL.md | 8 +-- .../Configuration/PortAllocation.cs | 62 ++++++++++++++----- .../SendInterruptSignalCommand.cs | 3 +- developer-cli/Commands/RestartCommand.cs | 10 +-- developer-cli/Commands/RunCommand.cs | 25 +------- 5 files changed, 54 insertions(+), 54 deletions(-) diff --git a/.claude/skills/aspire-restart/SKILL.md b/.claude/skills/aspire-restart/SKILL.md index 275207fb8..0f79ba277 100644 --- a/.claude/skills/aspire-restart/SKILL.md +++ b/.claude/skills/aspire-restart/SKILL.md @@ -6,7 +6,7 @@ description: Start or restart the .NET Aspire AppHost via the developer CLI. Alw # Restart Aspire ```bash -dotnet run --project developer-cli -- restart [] +dotnet run --project developer-cli -- restart ``` Use `developer-cli` exactly as written - do not expand to an absolute worktree path. @@ -15,7 +15,6 @@ Stops any running Aspire AppHost and starts a fresh instance. Detached by defaul Always use `restart`, even when nothing is running yet. It is a no-op when Aspire is not up, and the safe default in every other case. Never use the developer CLI's `run` command, `aspire run`, or `aspire restart`. -- `` - optional positional argument; written to `.workspace/port.txt` before Aspire starts - `--public-url ` - set `PUBLIC_URL` (e.g. an ngrok URL) ## When to use @@ -25,10 +24,9 @@ Always use `restart`, even when nothing is running yet. It is a no-op when Aspir - When hot reload breaks or stops picking up changes. - Before running e2e tests on a fresh stack. -## Picking the base port +## Port allocation -- In the git root: omit `` (uses the default). -- In a worktree: on the first start, pick the first free port from `10000`, `11000`, `12000`, `13000` (`lsof -i :` on macOS/Linux, `netstat -ano | findstr :` on Windows). Keep using that port for the lifetime of the worktree - never change it after. +Fully automatic. On a fresh checkout the developer CLI bootstraps `.workspace/port.txt`: the root gets the default base port; worktrees scan a fixed list of candidates and pick the first one whose ports are all free. Once written, the file is the authoritative allocation for the lifetime of the checkout. ## Output is fire-and-forget diff --git a/application/shared-kernel/SharedKernel/Configuration/PortAllocation.cs b/application/shared-kernel/SharedKernel/Configuration/PortAllocation.cs index 75516a470..70cc80ef3 100644 --- a/application/shared-kernel/SharedKernel/Configuration/PortAllocation.cs +++ b/application/shared-kernel/SharedKernel/Configuration/PortAllocation.cs @@ -1,8 +1,13 @@ +using System.Net; +using System.Net.Sockets; + namespace SharedKernel.Configuration; // Single source of truth for the local development port allocation. Reads .workspace/port.txt -// (single integer, whitespace-tolerant). If the file is missing, self-bootstraps with the default -// base port so the first run on a fresh checkout just works. Throws on a present-but-invalid file. +// (single integer, whitespace-tolerant). If the file is missing, self-bootstraps: the root +// checkout gets the default base port; a worktree scans a fixed list of candidate base ports +// and picks the first one whose ports are all free locally. Once written, port.txt is the +// authoritative allocation for the lifetime of the checkout. Throws on a present-but-invalid file. public sealed record PortAllocation(int BasePort) { private const int DefaultBasePort = 9000; @@ -11,6 +16,9 @@ public sealed record PortAllocation(int BasePort) private const string PortFileName = "port.txt"; + // Worktrees scan these in order and pick the first base port whose full allocation is free. + private static readonly int[] WorktreeCandidateBasePorts = [9100, 9200, 9300, 9400, 9500, 9600, 9700, 9800, 9900]; + public int AppGateway => BasePort; public int Aspire => BasePort + 1; @@ -74,9 +82,12 @@ public static PortAllocation LoadFrom(string repositoryRoot) if (!File.Exists(portFilePath)) { + var bootstrapPort = IsWorktree(repositoryRoot) + ? FindFreeBasePortForWorktree() + : DefaultBasePort; Directory.CreateDirectory(workspaceDirectory); - File.WriteAllText(portFilePath, $"{DefaultBasePort}{Environment.NewLine}"); - return new PortAllocation(DefaultBasePort); + File.WriteAllText(portFilePath, $"{bootstrapPort}{Environment.NewLine}"); + return new PortAllocation(bootstrapPort); } var content = File.ReadAllText(portFilePath).Trim(); @@ -90,28 +101,45 @@ public static PortAllocation LoadFrom(string repositoryRoot) return new PortAllocation(basePort); } - // True if .workspace/port.txt already exists -- distinguishes a fresh worktree from a configured one. + // True if .workspace/port.txt already exists -- distinguishes a fresh checkout from a configured one. public static bool PortFileExists(string repositoryRoot) { var portFilePath = Path.Combine(repositoryRoot, WorkspaceDirectoryName, PortFileName); return File.Exists(portFilePath); } - // Atomically writes the base port to .workspace/port.txt under the given repository root. - // Callers (e.g., the developer CLI's positional base-port argument on run/restart) use this to - // update the file before any code path lazily loads PortAllocation -- otherwise the lazy load - // would race with the write and could bootstrap with the default port. - public static void WriteBasePort(string repositoryRoot, int basePort) + // .git is a directory in the root checkout and a file in git worktrees. + private static bool IsWorktree(string repositoryRoot) { - if (basePort <= 0) throw new ArgumentOutOfRangeException(nameof(basePort), basePort, "Base port must be a positive integer."); + return File.Exists(Path.Combine(repositoryRoot, ".git")); + } - var workspaceDirectory = Path.Combine(repositoryRoot, WorkspaceDirectoryName); - var portFilePath = Path.Combine(workspaceDirectory, PortFileName); - var temporaryFilePath = portFilePath + ".tmp"; + private static int FindFreeBasePortForWorktree() + { + foreach (var candidate in WorktreeCandidateBasePorts) + { + var allocation = new PortAllocation(candidate); + if (allocation.AllPorts.All(IsTcpPortFree)) return candidate; + } - Directory.CreateDirectory(workspaceDirectory); - File.WriteAllText(temporaryFilePath, $"{basePort}{Environment.NewLine}"); - File.Move(temporaryFilePath, portFilePath, true); + throw new InvalidOperationException( + $"No free base port available for this worktree. Tried: {string.Join(", ", WorktreeCandidateBasePorts)}." + ); + } + + private static bool IsTcpPortFree(int port) + { + try + { + var listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + listener.Stop(); + return true; + } + catch (SocketException) + { + return false; + } } private static string FindRepositoryRoot(string startDirectory) diff --git a/developer-cli/Commands/ClaudeCommand/SendInterruptSignalCommand.cs b/developer-cli/Commands/ClaudeCommand/SendInterruptSignalCommand.cs index 9a234f745..ceee3a066 100644 --- a/developer-cli/Commands/ClaudeCommand/SendInterruptSignalCommand.cs +++ b/developer-cli/Commands/ClaudeCommand/SendInterruptSignalCommand.cs @@ -4,9 +4,8 @@ namespace DeveloperCli.Commands.ClaudeCommand; internal sealed class SendInterruptSignalCommand : Command { - private readonly Option _teamOption = new("--team", "-t") { Description = "Team name", Required = true }; - private readonly Option _agentOption = new("--agent", "-a") { Description = "Target agent name", Required = true }; + private readonly Option _teamOption = new("--team", "-t") { Description = "Team name", Required = true }; public SendInterruptSignalCommand() : base("send-interrupt-signal", "Send an interrupt signal to a team agent") { diff --git a/developer-cli/Commands/RestartCommand.cs b/developer-cli/Commands/RestartCommand.cs index 66fa108df..5a668c24c 100644 --- a/developer-cli/Commands/RestartCommand.cs +++ b/developer-cli/Commands/RestartCommand.cs @@ -11,18 +11,15 @@ public class RestartCommand : Command { public RestartCommand() : base("restart", "Stops any running Aspire AppHost and starts a fresh instance") { - var basePortArgument = new Argument("basePort") { Description = "Optional base port. If provided, written to .workspace/port.txt before Aspire starts.", DefaultValueFactory = _ => null }; var watchOption = new Option("--watch", "-w") { Description = "Enable watch mode for hot reload" }; var attachOption = new Option("--attach", "-a") { Description = "Keep the CLI process attached to the Aspire process (detached is the default)" }; var publicUrlOption = new Option("--public-url") { Description = "Set the PUBLIC_URL environment variable for the app (e.g., https://example.ngrok-free.app)" }; - Arguments.Add(basePortArgument); Options.Add(watchOption); Options.Add(attachOption); Options.Add(publicUrlOption); SetAction(parseResult => Execute( - parseResult.GetValue(basePortArgument), parseResult.GetValue(watchOption), parseResult.GetValue(attachOption), parseResult.GetValue(publicUrlOption) @@ -30,19 +27,16 @@ public RestartCommand() : base("restart", "Stops any running Aspire AppHost and ); } - private static void Execute(int? basePort, bool watch, bool attach, string? publicUrl) + private static void Execute(bool watch, bool attach, string? publicUrl) { Prerequisite.Ensure(Prerequisite.Dotnet, Prerequisite.Node, Prerequisite.Docker); - // Skip stop in a fresh worktree (no port.txt) -- nothing to stop, and the check would otherwise false-positive on another worktree's stack. + // Skip stop in a fresh checkout (no port.txt) -- nothing to stop, and the check would otherwise false-positive on another stack. if (PortAllocation.PortFileExists(Configuration.SourceCodeFolder) && RunCommand.IsAspireRunning()) { - // Stop on the OLD port before writing the new one; otherwise stop would look at the new port and miss the running old stack. RunCommand.StopAspire(); } - RunCommand.WriteBasePortIfProvided(basePort); - RunCommand.CheckForPortConflicts(); RunCommand.StartAspireAppHost(watch, attach, publicUrl); diff --git a/developer-cli/Commands/RunCommand.cs b/developer-cli/Commands/RunCommand.cs index 392904158..d71102b25 100644 --- a/developer-cli/Commands/RunCommand.cs +++ b/developer-cli/Commands/RunCommand.cs @@ -13,18 +13,15 @@ public class RunCommand : Command { public RunCommand() : base("run", "Runs Aspire AppHost (use --watch for hot reload)") { - var basePortArgument = new Argument("basePort") { Description = "Optional base port. If provided, written to .workspace/port.txt before Aspire starts.", DefaultValueFactory = _ => null }; var watchOption = new Option("--watch", "-w") { Description = "Enable watch mode for hot reload" }; var attachOption = new Option("--attach", "-a") { Description = "Keep the CLI process attached to the Aspire process (detached is the default)" }; var publicUrlOption = new Option("--public-url") { Description = "Set the PUBLIC_URL environment variable for the app (e.g., https://example.ngrok-free.app)" }; - Arguments.Add(basePortArgument); Options.Add(watchOption); Options.Add(attachOption); Options.Add(publicUrlOption); SetAction(parseResult => Execute( - parseResult.GetValue(basePortArgument), parseResult.GetValue(watchOption), parseResult.GetValue(attachOption), parseResult.GetValue(publicUrlOption) @@ -34,9 +31,6 @@ public class RunCommand : Command // The CLI binary is published outside the repo, so PortAllocation.Load (which walks up from // AppContext.BaseDirectory) cannot find the repo. Use the CLI's known SourceCodeFolder instead. - // Re-read on every access so a run/restart invocation with a positional base port picks up the - // freshly written .workspace/port.txt without any cached PortAllocation lingering from earlier - // in the call. internal static PortAllocation Ports => PortAllocation.LoadFrom(Configuration.SourceCodeFolder); internal static int AspirePort => Ports.Aspire; @@ -45,37 +39,24 @@ public class RunCommand : Command internal static int ResourceServicePort => Ports.ResourceService; - private static void Execute(int? basePort, bool watch, bool attach, string? publicUrl) + private static void Execute(bool watch, bool attach, string? publicUrl) { Prerequisite.Ensure(Prerequisite.Dotnet, Prerequisite.Node, Prerequisite.Docker); - // Refuse if Aspire is already on the currently configured port -- updating port.txt now would orphan the running stack. + // Refuse if Aspire is already on the currently configured port. // Skipped in a fresh worktree (no port.txt) where the check would false-positive on another worktree's stack. if (PortAllocation.PortFileExists(Configuration.SourceCodeFolder) && IsAspireRunning()) { var alias = Configuration.AliasName; - var message = basePort is null - ? $"Aspire AppHost is already running on port {AspirePort}. Run '{alias} stop' to stop it or '{alias} restart' to start a fresh instance." - : $"Aspire AppHost is already running on port {AspirePort}. Run '{alias} stop' first or use '{alias} restart {basePort}' to switch."; - AnsiConsole.MarkupLine($"[yellow]{message}[/]"); + AnsiConsole.MarkupLine($"[yellow]Aspire AppHost is already running on port {AspirePort}. Run '{alias} stop' to stop it or '{alias} restart' to start a fresh instance.[/]"); Environment.Exit(1); } - WriteBasePortIfProvided(basePort); - CheckForPortConflicts(); StartAspireAppHost(watch, attach, publicUrl); } - internal static void WriteBasePortIfProvided(int? basePort) - { - if (basePort is null) return; - - PortAllocation.WriteBasePort(Configuration.SourceCodeFolder, basePort.Value); - AnsiConsole.MarkupLine($"[blue]Set base port to {basePort.Value}.[/]"); - } - internal static bool IsAspireRunning() { // Check the main Aspire port From 1610cfc3732365f433e3622ea1f2d502e612477d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 28 Apr 2026 16:17:24 +0200 Subject: [PATCH 8/9] Clarify interrupt hook message so agents that already saw the ID continue --- .claude/hooks/check-interrupt.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/hooks/check-interrupt.sh b/.claude/hooks/check-interrupt.sh index 21c7c2c2e..f5deee466 100755 --- a/.claude/hooks/check-interrupt.sh +++ b/.claude/hooks/check-interrupt.sh @@ -16,7 +16,7 @@ for SIGNALS_DIR in "$HOME/.claude/teams"/*/signals; do rm -f "$TMP_FILE" [ -z "$MESSAGE" ] && continue ESCAPED_MSG=$(printf '%s' "$MESSAGE" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ') - printf '{"decision":"block","reason":"INTERRUPT: %s"}\n' "$ESCAPED_MSG" + printf '{"decision":"block","reason":"INTERRUPT: %s. If you have ALREADY processed a message starting with that ID, continue your current work from there. Otherwise stop and wait for it."}\n' "$ESCAPED_MSG" exit 0 done exit 0 From c6f43888951e8558c2d48beab89be724694a5f3a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 28 Apr 2026 16:23:50 +0200 Subject: [PATCH 9/9] Reuse PortAllocation worktree candidate list in tests via InternalsVisibleTo --- .../Configuration/PortAllocation.cs | 2 +- .../SharedKernel/SharedKernel.csproj | 4 ++ .../Configuration/PortAllocationTests.cs | 50 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 application/shared-kernel/Tests/Configuration/PortAllocationTests.cs diff --git a/application/shared-kernel/SharedKernel/Configuration/PortAllocation.cs b/application/shared-kernel/SharedKernel/Configuration/PortAllocation.cs index 70cc80ef3..536d4802d 100644 --- a/application/shared-kernel/SharedKernel/Configuration/PortAllocation.cs +++ b/application/shared-kernel/SharedKernel/Configuration/PortAllocation.cs @@ -17,7 +17,7 @@ public sealed record PortAllocation(int BasePort) private const string PortFileName = "port.txt"; // Worktrees scan these in order and pick the first base port whose full allocation is free. - private static readonly int[] WorktreeCandidateBasePorts = [9100, 9200, 9300, 9400, 9500, 9600, 9700, 9800, 9900]; + internal static readonly int[] WorktreeCandidateBasePorts = [9100, 9200, 9300, 9400, 9500, 9600, 9700, 9800, 9900]; public int AppGateway => BasePort; diff --git a/application/shared-kernel/SharedKernel/SharedKernel.csproj b/application/shared-kernel/SharedKernel/SharedKernel.csproj index b314707de..5b117377e 100644 --- a/application/shared-kernel/SharedKernel/SharedKernel.csproj +++ b/application/shared-kernel/SharedKernel/SharedKernel.csproj @@ -60,4 +60,8 @@ + + + + diff --git a/application/shared-kernel/Tests/Configuration/PortAllocationTests.cs b/application/shared-kernel/Tests/Configuration/PortAllocationTests.cs new file mode 100644 index 000000000..3e7fc369a --- /dev/null +++ b/application/shared-kernel/Tests/Configuration/PortAllocationTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using SharedKernel.Configuration; +using Xunit; + +namespace SharedKernel.Tests.Configuration; + +public sealed class PortAllocationTests : IDisposable +{ + private const int DefaultBasePort = 9000; + + private readonly string _temporaryDirectory = Path.Combine(Path.GetTempPath(), $"PortAllocationTests-{Guid.NewGuid():N}"); + + public PortAllocationTests() + { + Directory.CreateDirectory(_temporaryDirectory); + } + + public void Dispose() + { + if (Directory.Exists(_temporaryDirectory)) Directory.Delete(_temporaryDirectory, true); + } + + [Fact] + public void LoadFrom_WhenPortFileMissingAndGitIsFile_ShouldPickFreeWorktreeCandidate() + { + // Arrange: a worktree has `.git` as a FILE (not a directory) + File.WriteAllText(Path.Combine(_temporaryDirectory, ".git"), "gitdir: /some/other/path"); + + // Act + var allocation = PortAllocation.LoadFrom(_temporaryDirectory); + + // Assert + PortAllocation.WorktreeCandidateBasePorts.Should().Contain(allocation.BasePort); + File.Exists(Path.Combine(_temporaryDirectory, ".workspace", "port.txt")).Should().BeTrue(); + } + + [Fact] + public void LoadFrom_WhenPortFileMissingAndGitIsDirectory_ShouldPickDefaultBasePort() + { + // Arrange: a normal repository root has `.git` as a DIRECTORY + Directory.CreateDirectory(Path.Combine(_temporaryDirectory, ".git")); + + // Act + var allocation = PortAllocation.LoadFrom(_temporaryDirectory); + + // Assert + allocation.BasePort.Should().Be(DefaultBasePort); + File.Exists(Path.Combine(_temporaryDirectory, ".workspace", "port.txt")).Should().BeTrue(); + } +}