Merge branch 'MCCTeam:master' into master

This commit is contained in:
harry 2026-06-20 13:53:07 +01:00 committed by GitHub
commit 37f71d4494
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
639 changed files with 126385 additions and 17202 deletions

1
.claude/skills Symbolic link
View file

@ -0,0 +1 @@
../.skills

16
.codex/hooks.json Normal file
View file

@ -0,0 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 \"$(git rev-parse --show-toplevel)/.codex/hooks/pre_tool_use_mcc_build_guard.py\"",
"statusMessage": "Checking MCC build command policy"
}
]
}
]
}
}

View file

@ -0,0 +1,67 @@
#!/usr/bin/env python3
import json
import re
import sys
RAW_DOTNET_BUILD_RE = re.compile(r"(^|[\s;&|()])dotnet\s+build(\s|$)")
ABSOLUTE_DOTNET_BUILD_RE = re.compile(r"(^|[\s;&|()])/\S*dotnet\s+build(\s|$)")
RAW_DOTNET_PUBLISH_RE = re.compile(r"(^|[\s;&|()])dotnet\s+publish(\s|$)")
ABSOLUTE_DOTNET_PUBLISH_RE = re.compile(r"(^|[\s;&|()])/\S*dotnet\s+publish(\s|$)")
def main() -> int:
try:
payload = json.load(sys.stdin)
except json.JSONDecodeError:
return 0
command = payload.get("tool_input", {}).get("command", "")
if not isinstance(command, str) or not command:
return 0
if ABSOLUTE_DOTNET_BUILD_RE.search(command) or ABSOLUTE_DOTNET_PUBLISH_RE.search(command):
return 0
if RAW_DOTNET_BUILD_RE.search(command):
response = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"Raw 'dotnet build' is blocked in this repository. "
"Use 'source tools/mcc-env.sh && mcc-build' instead so MCC temp-build routing stays active. "
"If you intentionally need the raw .NET CLI, call it by absolute path such as '/usr/bin/dotnet build ...' to bypass this guard."
),
},
"systemMessage": (
"Blocked raw 'dotnet build'. Use 'source tools/mcc-env.sh && mcc-build'. "
"If you intentionally need raw .NET CLI behavior, call '/usr/bin/dotnet build ...' explicitly."
),
}
json.dump(response, sys.stdout)
sys.stdout.write("\n")
elif RAW_DOTNET_PUBLISH_RE.search(command):
response = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"Raw 'dotnet publish' is blocked in this repository. "
"Use 'source tools/mcc-env.sh && mcc-publish --rid <RID>' instead so MCC publish defaults stay aligned with the repo workflow. "
"If you intentionally need the raw .NET CLI, call it by absolute path such as '/usr/bin/dotnet publish ...' to bypass this guard."
),
},
"systemMessage": (
"Blocked raw 'dotnet publish'. Use 'source tools/mcc-env.sh && mcc-publish --rid <RID>'. "
"If you intentionally need raw .NET CLI behavior, call '/usr/bin/dotnet publish ...' explicitly."
),
}
json.dump(response, sys.stdout)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())

1
.codex/skills Symbolic link
View file

@ -0,0 +1 @@
../.skills

1
.cursor/skills Symbolic link
View file

@ -0,0 +1 @@
../.skills

3
.cursorindexingignore Normal file
View file

@ -0,0 +1,3 @@
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
.specstory/**

View file

@ -8,101 +8,39 @@ on:
env:
PROJECT: "MinecraftClient"
target-version: "net7.0"
compile-flags: "--self-contained=true -c Release -p:UseAppHost=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded"
target-version: "net10.0"
dotnet-version: "10.0.x"
compile-flags: "--self-contained=true -c Release -p:UseAppHost=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true"
jobs:
build:
runs-on: ubuntu-latest
if: ${{ always() && needs.fetch-translations.result != 'failure' }}
needs: [determine-build, fetch-translations]
timeout-minutes: 15
strategy:
matrix:
target: [win-x86, win-x64, win-arm, win-arm64, linux-x64, linux-arm, linux-arm64, osx-x64, osx-arm64]
determine-build:
runs-on: ubuntu-slim
outputs:
skip: ${{ steps.check-skip.outputs.skip }}
steps:
- name: Checkout
uses: actions/checkout@v3
if: ${{ always() && needs.fetch-translations.result == 'skipped' }}
with:
fetch-depth: 0
submodules: 'true'
- name: Get Current Date
- name: Check skip CI
id: check-skip
run: |
echo date=$(date +'%Y%m%d') >> $GITHUB_ENV
echo date_dashed=$(date -u +'%Y-%m-%d') >> $GITHUB_ENV
- name: Restore Translations (if available)
uses: actions/cache/restore@v3
with:
path: ${{ github.workspace }}/*
key: "translation-${{ github.sha }}"
restore-keys: "translation-"
- name: Setup Environment Variables (early)
run: |
echo project-path=${{ github.workspace }}/${{ env.PROJECT }} >> $GITHUB_ENV
echo file-ext=${{ (startsWith(matrix.target, 'win') && '.exe') || ' ' }} >> $GITHUB_ENV
- name: Setup Environment Variables
run: |
echo target-out-path=${{ env.project-path }}/bin/Release/${{ env.target-version }}/${{ matrix.target }}/publish/ >> $GITHUB_ENV
echo assembly-info=${{ env.project-path }}/Properties/AssemblyInfo.cs >> $GITHUB_ENV
echo build-version-info=${{ env.date }}-${{ github.run_number }} >> $GITHUB_ENV
echo commit=$(echo ${{ github.sha }} | cut -c 1-7) >> $GITHUB_ENV
- name: Setup Environment Variables (late)
run: |
echo built-executable-path=${{ env.target-out-path }}${{ env.PROJECT }}${{ env.file-ext }} >> $GITHUB_ENV
- name: Set Version Info
run: |
echo '' >> ${{ env.assembly-info }}
echo "[assembly: AssemblyConfiguration(\"GitHub build ${{ github.run_number }}, built on ${{ env.date_dashed }} from commit ${{ env.commit }}\")]" >> ${{ env.assembly-info }}
sed -i -e 's|SentryDSN = "";|SentryDSN = "${{ secrets.SENTRY_DSN }}";|g' ${{ env.project-path }}/Program.cs
- name: Build Target
run: dotnet publish ${{ env.project-path }}.sln -f ${{ env.target-version }} -r ${{ matrix.target }} ${{ env.compile-flags }}
LOWER=$(echo "$COMMIT_MSG" | tr '[:upper:]' '[:lower:]')
if echo "$LOWER" | grep -qE 'skip.?ci|ci.?skip'; then
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
env:
DOTNET_NOLOGO: true
COMMIT_MSG: ${{ github.event.head_commit.message }}
- name: Rename Binary
run: |
mv ${{ env.built-executable-path }} ${{ env.PROJECT }}-${{ env.build-version-info }}-${{ matrix.target }}${{ (startsWith(matrix.target, 'win') && '.exe') || ' ' }}
- name: Wait
# We wait before creating a release because we might run into a race condition
# while creating a new tag (as opposed to using the existing tag, if any) since we're running builds in parallel.
run: |
sleep 5s
- name: Create Release
uses: ncipollo/release-action@v1.14.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: ${{ env.PROJECT }}-${{ env.build-version-info }}-${{ matrix.target }}${{ (startsWith(matrix.target, 'win') && '.exe') || ' ' }}
tag: ${{ format('{0}-{1}', env.date, github.run_number) }}
name: '${{ env.build-version-info }}: ${{ github.event.head_commit.message }}'
generateReleaseNotes: true
artifactErrorsFailBuild: true
allowUpdates: true
makeLatest: true
omitBodyDuringUpdate: true
omitNameDuringUpdate: true
replacesArtifacts: false
fetch-translations:
strategy:
fail-fast: true
runs-on: ubuntu-latest
needs: determine-build
# Translations will only be fetched in the MCCTeam repository, since it needs crowdin secrets.
if: ${{ github.repository == 'MCCTeam/Minecraft-Console-Client' }}
if: ${{ needs.determine-build.outputs.skip != 'true' }}
timeout-minutes: 15
steps:
steps:
- name: Check cache
uses: actions/cache/restore@v3
id: cache-check
@ -111,19 +49,31 @@ jobs:
key: "translation-${{ github.sha }}"
lookup-only: true
restore-keys: "translation-"
- name: Checkout
uses: actions/checkout@v3
if: steps.cache-check.outputs.cache-hit != 'true'
uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: 'true'
- name: Check Crowdin secrets
id: crowdin-check
run: |
if [ -z "$CROWDIN_PROJECT_ID" ] || [ -z "$CROWDIN_PERSONAL_TOKEN" ]; then
echo "available=false" >> $GITHUB_OUTPUT
else
echo "available=true" >> $GITHUB_OUTPUT
fi
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
- name: Download translations from crowdin
uses: crowdin/github-action@v1.6.0
if: steps.cache-check.outputs.cache-hit != 'true'
uses: crowdin/github-action@v2.4.0
if: steps.cache-check.outputs.cache-hit != 'true' && steps.crowdin-check.outputs.available == 'true'
with:
upload_sources: false
upload_sources: ${{ github.repository == 'MCCTeam/Minecraft-Console-Client' }}
upload_translations: false
download_translations: true
@ -143,12 +93,154 @@ jobs:
with:
path: ${{ github.workspace }}/*
key: "translation-${{ github.sha }}"
determine-build:
runs-on: ubuntu-latest
strategy:
fail-fast: true
if: ${{ !contains(github.event.head_commit.message, 'skip') || !contains(github.event.head_commit.message, 'skipci')}}
create-tag:
runs-on: ubuntu-slim
timeout-minutes: 5 # Wait 5 minutes in case of network issues/etc
needs: determine-build
if: ${{ needs.determine-build.outputs.skip != 'true' }}
steps:
- name: dummy action
run: "echo 'dummy action that checks if the build is to be skipped, if it is, this action does not run to break the entire build action'"
- id: make-tag
run: |
TAG="$(date -u +'%Y%m%d')-${{ github.run_number }}"
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "TAG=$TAG" >> $GITHUB_ENV
- name: Create Release Tag
uses: actions/github-script@v7
with:
script: |
const tag = process.env.TAG;
try {
await github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `refs/tags/${tag}`,
sha: context.sha
});
} catch(error) {
if (error.message.includes('already exists')) {
console.log(`Tag ${tag} already exists`);
} else {
throw error;
}
}
outputs:
build-tag: ${{ steps.make-tag.outputs.tag }}
build:
runs-on: ubuntu-latest
# Check if we're not skipping build, tag is created, and translations successfully fetched (or skipped)
if: ${{ needs.determine-build.outputs.skip != 'true' &&
needs.create-tag.result == 'success' &&
(needs.fetch-translations.result == 'success' || needs.fetch-translations.result == 'skipped')
}}
needs: [determine-build, fetch-translations, create-tag]
timeout-minutes: 15
strategy:
matrix:
target: [win-x86, win-x64, win-arm64, linux-x64, linux-arm, linux-arm64, osx-x64, osx-arm64]
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: 'true'
- name: Get Current Date
run: |
echo date_dashed=$(date -u +'%Y-%m-%d') >> $GITHUB_ENV
- name: Restore Translations (if available)
uses: actions/cache/restore@v3
with:
path: ${{ github.workspace }}/*
key: "translation-${{ github.sha }}"
restore-keys: "translation-"
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.dotnet-version }}
- name: Setup Environment Variables
run: |
PROJECT_PATH=${{ github.workspace }}/${{ env.PROJECT }}
FILE_EXT=${{ (startsWith(matrix.target, 'win') && '.exe') || '' }}
TARGET_OUT_PATH=$PROJECT_PATH/bin/Release/${{ env.target-version }}/${{ matrix.target }}/publish/
echo "project-path=$PROJECT_PATH" >> $GITHUB_ENV
echo "file-ext=$FILE_EXT" >> $GITHUB_ENV
echo "target-out-path=$TARGET_OUT_PATH" >> $GITHUB_ENV
echo "assembly-info=$PROJECT_PATH/Properties/AssemblyInfo.cs" >> $GITHUB_ENV
echo "build-version-info=${{ needs.create-tag.outputs.build-tag }}" >> $GITHUB_ENV
echo "commit=$(echo ${{ github.sha }} | cut -c 1-7)" >> $GITHUB_ENV
- name: Setup Binaries Path
run: |
echo built-executable-path=${{ env.target-out-path }}${{ env.PROJECT }}${{ env.file-ext }} >> $GITHUB_ENV
- name: Set Version Info
run: |
echo '' >> ${{ env.assembly-info }}
echo "[assembly: AssemblyConfiguration(\"GitHub build ${{ github.run_number }}, built on ${{ env.date_dashed }} from commit ${{ env.commit }}\")]" >> ${{ env.assembly-info }}
- name: Inject Sentry DSN (if applicable)
if: ${{ github.repository == 'MCCTeam/Minecraft-Console-Client' }}
run: |
grep -q 'SentryDSN = "";' ${{ env.project-path }}/Program.cs || { echo "SentryDSN pattern not found in Program.cs"; exit 1; }
sed -i -e 's|SentryDSN = "";|SentryDSN = "${{ secrets.SENTRY_DSN }}";|g' ${{ env.project-path }}/Program.cs
- name: Build Target
run: dotnet publish ${{ env.project-path }}/${{ env.PROJECT }}.csproj -f ${{ env.target-version }} -r ${{ matrix.target }} ${{ env.compile-flags }}
env:
DOTNET_NOLOGO: true
- name: Rename Binary
run: |
mv ${{ env.built-executable-path }} ${{ env.PROJECT }}-${{ env.build-version-info }}-${{ matrix.target }}${{ env.file-ext }}
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.PROJECT }}-${{ env.build-version-info }}-${{ matrix.target }}
path: ${{ env.PROJECT }}-${{ env.build-version-info }}-${{ matrix.target }}${{ env.file-ext }}
if-no-files-found: error
create-release:
runs-on: ubuntu-slim
needs: [create-tag, build]
if: ${{ needs.build.result == 'success' && needs.create-tag.result == 'success' }}
steps:
- name: Download All Artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
merge-multiple: true
- name: Truncate commit message for release name
id: release-name
run: |
SUBJECT=$(echo "$COMMIT_MSG" | head -n 1)
MAX=220
TRUNCATED="${SUBJECT:0:$MAX}"
echo "name=${BUILD_TAG}: $TRUNCATED" >> $GITHUB_OUTPUT
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
BUILD_TAG: ${{ needs.create-tag.outputs.build-tag }}
- name: Create Release
uses: ncipollo/release-action@v1.14.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: "artifacts/**/*"
tag: ${{ needs.create-tag.outputs.build-tag }}
name: ${{ steps.release-name.outputs.name }}
generateReleaseNotes: true
artifactErrorsFailBuild: true
allowUpdates: true
makeLatest: true
omitBodyDuringUpdate: true
omitNameDuringUpdate: true
replacesArtifacts: true

View file

@ -6,8 +6,27 @@ on:
workflow_dispatch:
jobs:
check-secrets:
runs-on: ubuntu-latest
outputs:
has-deploy-token: ${{ steps.check.outputs.has-deploy-token }}
steps:
- name: Check required secrets
id: check
run: |
if [ -z "$GH_PAGES_TOKEN" ]; then
echo "has-deploy-token=false" >> $GITHUB_OUTPUT
echo "::warning::GH_PAGES_TOKEN is not set, skipping documentation deployment."
else
echo "has-deploy-token=true" >> $GITHUB_OUTPUT
fi
env:
GH_PAGES_TOKEN: ${{ secrets.GH_PAGES_TOKEN }}
Build:
runs-on: ubuntu-latest
needs: check-secrets
if: ${{ needs.check-secrets.outputs.has-deploy-token == 'true' }}
steps:
@ -17,8 +36,22 @@ jobs:
fetch-depth: 0
submodules: 'true'
- name: Check Crowdin secrets
id: crowdin-check
run: |
if [ -z "$CROWDIN_PROJECT_ID" ] || [ -z "$CROWDIN_PERSONAL_TOKEN" ]; then
echo "available=false" >> $GITHUB_OUTPUT
echo "::warning::Crowdin secrets not set, skipping translation download."
else
echo "available=true" >> $GITHUB_OUTPUT
fi
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
- name: Download translations from crowdin
uses: crowdin/github-action@v1.6.0
if: ${{ steps.crowdin-check.outputs.available == 'true' }}
uses: crowdin/github-action@v2.4.0
with:
upload_sources: false
upload_translations: false
@ -40,7 +73,7 @@ jobs:
ACCESS_TOKEN: ${{ secrets.GH_PAGES_TOKEN }}
TARGET_REPO: MCCTeam/MCCTeam.github.io
TARGET_BRANCH: master
BUILD_SCRIPT: yarn --cwd ./docs/ && yarn --cwd ./docs/ docs:build
BUILD_SCRIPT: export NODE_OPTIONS=--max-old-space-size=8192 && yarn --cwd ./docs/ && yarn --cwd ./docs/ docs:build
BUILD_DIR: docs/.vuepress/dist
COMMIT_MESSAGE: Build from ${{ github.sha }}
CNAME: https://mccteam.github.io

35
.gitignore vendored
View file

@ -8,10 +8,15 @@
/Other/
/.vs/
SessionCache.ini
.*
!/.github
/packages
# OS-generated files
.DS_Store
Thumbs.db
desktop.ini
ehthumbs.db
._*
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
@ -383,7 +388,7 @@ FodyWeavers.xsd
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/launch.json``
!.vscode/extensions.json
*.code-workspace
@ -404,6 +409,8 @@ FodyWeavers.xsd
# docs
!/docs/.vuepress
/docs/.vuepress/.cache
/docs/.vuepress/.temp
/docs/.vuepress/dist
# translations
@ -416,3 +423,25 @@ FodyWeavers.xsd
/docs/l10n/
/docs/.vuepress/public/MCC-README/
/docs/superpowers/
# Floder to store the decompiled Minecraft official source code
/MinecraftOfficial/
# Possible debug files
/lang/*
/mcc_input.txt
/MinecraftClient.ini
/MinecraftClient.backup.ini
# SpecStory files
/.specstory/
/.vscode/settings.json
# Other
/Sentry/
/downloads/
server.pid
# Crowdin translation automation working directory
/.crowdin-translate/

View file

@ -0,0 +1,980 @@
---
name: csharp-best-practices
description: >
C# 14 / .NET 10 coding conventions, idiomatic patterns, and performance best practices
for the Minecraft Console Client codebase. Use when writing, reviewing, or modifying C# code.
---
# C# 14 / .NET 10 Best Practices
Target: **.NET 10**, **C# 14**, nullable enabled.
Sources: [MS C# Conventions](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions) · [.NET Runtime Style](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md) · [C# 14 Proposals](https://github.com/dotnet/csharplang/blob/main/Language-Version-History.md) · [C# 13 Docs](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-13)
## Naming
| Element | Style | Example |
|---|---|---|
| Type, method, property, const, enum member | PascalCase | `PacketHandler`, `MaxRetries`, `GameMode.Survival` |
| Interface | `I` + PascalCase | `IChatBot` |
| Private instance field | `_camelCase` | `_handler` |
| Private static field | `s_camelCase` | `s_defaultTimeout` |
| Thread-static field | `t_camelCase` | `t_cachedBuffer` |
| Local, parameter | camelCase | `packetId` |
| Type parameter | `T` + PascalCase | `TResult` |
| Namespace | PascalCase | `MinecraftClient.Protocol` |
| Async methods | Suffix `Async` | `ConnectAsync()`, `ReadPacketAsync()` |
```csharp
// CORRECT: naming conventions
private readonly Dictionary<int, Entity> _entities = new();
private static readonly TimeSpan s_reconnectDelay = TimeSpan.FromSeconds(5);
public int PacketCount { get; private set; }
public async Task<bool> ConnectAsync(CancellationToken ct) { }
```
```csharp
// WRONG: naming violations
private Dictionary<int, Entity> entities = new(); // missing _
private static TimeSpan reconnectDelay; // missing s_
public int packet_count { get; set; } // snake_case
public async Task<bool> Connect(CancellationToken ct) { } // missing Async suffix
```
## C# 14 Features
### Extension Members (C# 14)
Declare extension methods, properties, and operators inside `extension(...)` blocks. Replaces `this`-parameter pattern for new extensions.
```csharp
// CORRECT: extension property + method (C# 14)
public static class EntityExtensions
{
extension(Entity entity)
{
public bool IsAlive => entity.Health > 0;
public void Heal(int amount) => entity.Health = Math.Min(entity.Health + amount, 20);
}
extension<T>(IEnumerable<T> items)
{
public bool IsEmpty => !items.GetEnumerator().MoveNext();
}
}
```
```csharp
// WRONG: classic extension method when C# 14 extension block is available
public static bool IsAlive(this Entity entity) => entity.Health > 0;
```
### `field` Keyword in Properties (C# 14)
Access the auto-generated backing field without declaring it. Mix auto and full accessors.
```csharp
// CORRECT: lazy init with field keyword
public string DisplayName => field ??= ComputeDisplayName();
// CORRECT: INotifyPropertyChanged pattern
public bool IsConnected
{
get;
set
{
if (field == value) return;
field = value;
OnPropertyChanged();
}
}
```
```csharp
// WRONG: manual backing field when field keyword suffices
private string? _displayName;
public string DisplayName => _displayName ??= ComputeDisplayName();
```
### Null-Conditional Assignment (C# 14)
Assign through `?.` — RHS is only evaluated when receiver is non-null.
```csharp
// CORRECT: null-conditional assignment
player?.Health = 20;
connection?.OnDisconnect += HandleDisconnect;
inventory?[slot] = newItem;
```
```csharp
// WRONG: manual null check for simple assignment
if (player is not null)
player.Health = 20;
```
### Simple Lambda Parameters with Modifiers (C# 14)
Omit types on lambda parameters while still applying modifiers.
```csharp
// CORRECT: modifiers without explicit types
TryParse<int> parse = (text, out result) => int.TryParse(text, out result);
ReadOnlySpan<int> data = [1, 2, 3];
ProcessSpan((scoped span) => span.Length);
```
```csharp
// WRONG: fully explicit types just for a modifier
TryParse<int> parse = (string text, out int result) => int.TryParse(text, out result);
```
### First-Class Span Types (C# 14)
Implicit conversions between `T[]`, `Span<T>`, and `ReadOnlySpan<T>` — no explicit cast needed. Extension methods on `ReadOnlySpan<T>` apply to arrays and spans automatically.
```csharp
// CORRECT: pass array where ReadOnlySpan<T> is expected (C# 14)
int[] data = [1, 2, 3];
bool found = data.StartsWith(1); // ReadOnlySpan<int> extension resolved
ReadOnlySpan<byte> span = stackalloc byte[4];
```
### Unbound Generics in `nameof` (C# 14)
```csharp
// CORRECT: no need to pick a dummy type argument
string name = nameof(Dictionary<,>); // "Dictionary"
string prop = nameof(List<>.Count); // "Count"
```
```csharp
// WRONG: arbitrary type argument just to satisfy nameof
string name = nameof(Dictionary<object, object>);
```
### Partial Events and Constructors (C# 14)
Separate declaration from implementation for source-generator scenarios.
```csharp
// CORRECT: partial constructor for source-gen interop
partial class ServerConnection
{
partial ServerConnection(string host, int port);
}
partial class ServerConnection
{
partial ServerConnection(string host, int port) { /* generated */ }
}
```
### `#:` Ignored Directives (C# 14)
For file-based `dotnet run app.cs` programs — ignored by the compiler.
```csharp
#!/usr/bin/dotnet run
#:package System.CommandLine@2.0.0-*
Console.WriteLine("Hello");
```
## C# 13 Features
### `Lock` Object (C# 13)
Use `System.Threading.Lock` instead of `lock(obj)` on arbitrary objects.
```csharp
// CORRECT: dedicated Lock type
private readonly Lock _lock = new();
public void Enqueue(ChatMessage msg) { lock (_lock) _queue.Add(msg); }
```
```csharp
// WRONG: locking on an object reference
private readonly object _syncRoot = new();
lock (_syncRoot) { }
```
### `params` Collections (C# 13)
`params` now works with `ReadOnlySpan<T>`, `Span<T>`, `IEnumerable<T>`, and other collection types.
```csharp
// CORRECT: params span avoids array allocation
public void Log(params ReadOnlySpan<string> messages)
{
foreach (var msg in messages) Console.WriteLine(msg);
}
```
### Partial Properties (C# 13)
```csharp
// CORRECT: partial property for source generators
partial class Config
{
public partial string Host { get; set; }
}
partial class Config
{
public partial string Host { get => _host; set => _host = value; }
private string _host = "";
}
```
## C# 12 Features
### Primary Constructors
Use for simple parameter capture. Parameters are `camelCase`, mutable — assign to `readonly` fields when immutability matters.
```csharp
// CORRECT: primary constructor captures dependencies
public class ChatLogger(string logFilePath, bool appendMode) : ChatBot
{
private readonly StreamWriter _writer = new(logFilePath, appendMode);
public override void GetText(string text) => _writer.WriteLine(text);
}
```
```csharp
// WRONG: verbose constructor boilerplate for simple capture
public class ChatLogger : ChatBot
{
private readonly StreamWriter _writer;
public ChatLogger(string logFilePath, bool appendMode)
{
_writer = new StreamWriter(logFilePath, appendMode);
}
public override void GetText(string text) => _writer.WriteLine(text);
}
```
### Collection Expressions
Use `[...]` and `..` spread for arrays, lists, spans.
```csharp
// CORRECT: collection expressions (C# 12)
int[] ids = [1, 2, 3];
List<string> names = ["Steve", "Alex"];
ReadOnlySpan<byte> header = [0xFE, 0x01]; // no heap alloc
int[] combined = [..firstArray, ..secondArray, 42];
IReadOnlyList<string> empty = [];
```
```csharp
// WRONG: verbose initialization
int[] ids = new int[] { 1, 2, 3 };
var names = new List<string> { "Steve", "Alex" };
ReadOnlySpan<byte> header = new byte[] { 0xFE, 0x01 }; // allocates
var combined = firstArray.Concat(secondArray).Append(42).ToArray();
```
### Type Aliases
```csharp
// CORRECT: alias complex types for readability
using Coordinate = (int X, int Y, int Z);
using PacketMap = System.Collections.Generic.Dictionary<int, System.Action<byte[]>>;
```
### Default Lambda Parameters
```csharp
// CORRECT: C# 12
var greet = (string name, string prefix = "Player") => $"{prefix} {name}";
```
## Modern Syntax (C# 1014)
### File-Scoped Namespaces
```csharp
// CORRECT: file-scoped namespace — one per file, less nesting
namespace MinecraftClient.ChatBots;
public class MyBot : ChatBot { }
```
```csharp
// WRONG: block-scoped namespace adds unnecessary nesting
namespace MinecraftClient.ChatBots
{
public class MyBot : ChatBot { }
}
```
### Target-Typed `new`
Use when the type is obvious from the left-hand side.
```csharp
// CORRECT: target-typed new
private readonly Dictionary<string, int> _scores = new();
List<Entity> entities = new(capacity: 256);
```
```csharp
// WRONG: redundant type name
private readonly Dictionary<string, int> _scores = new Dictionary<string, int>();
```
### Pattern Matching
Prefer patterns over type-casting chains and complex boolean logic.
```csharp
// CORRECT: is-pattern with declaration and property patterns
if (entity is Player { Health: > 0 } player)
SendMessage($"{player.Name} is alive");
```
```csharp
// WRONG: manual cast and multi-step check
if (entity is Player)
{
var player = (Player)entity;
if (player.Health > 0)
SendMessage($"{player.Name} is alive");
}
```
```csharp
// CORRECT: switch expression
public string GetStatusLabel(GameMode mode) => mode switch
{
GameMode.Survival => "Survival",
GameMode.Creative => "Creative",
GameMode.Adventure => "Adventure",
GameMode.Spectator => "Spectator",
_ => throw new ArgumentOutOfRangeException(nameof(mode))
};
```
```csharp
// WRONG: switch statement with returns
public string GetStatusLabel(GameMode mode)
{
switch (mode)
{
case GameMode.Survival: return "Survival";
case GameMode.Creative: return "Creative";
default: throw new ArgumentOutOfRangeException(nameof(mode));
}
}
```
```csharp
// CORRECT: property patterns for compound conditions
if (response is { StatusCode: >= 200 and < 300, Content.Length: > 0 })
ProcessResponse(response);
```
```csharp
// WRONG: multiple chained conditions
if (response != null && response.StatusCode >= 200
&& response.StatusCode < 300 && response.Content != null
&& response.Content.Length > 0)
ProcessResponse(response);
```
```csharp
// CORRECT: relational, logical, and list patterns
if (health is > 0 and <= 6) LogToConsole("Low health!");
if (args is [var command, var target, ..]) ProcessCommand(command, target);
```
### Raw String Literals
Use for JSON, regex, multi-line strings.
```csharp
// CORRECT: raw string literal
string json = """
{ "username": "Steve", "action": "connect" }
""";
string pattern = """<\w+>""";
```
```csharp
// WRONG: escaped quotes
string json = "{ \"username\": \"Steve\", \"action\": \"connect\" }";
```
### Records
Use `record` for immutable data carriers and DTOs. Use `record struct` for small value types.
```csharp
// CORRECT: record for data carrier
public record PlayerInfo(string Name, Guid Uuid, GameMode Mode);
public record struct ChunkCoord(int X, int Z);
var updated = info with { Mode = GameMode.Creative };
```
```csharp
// WRONG: full class for a simple data carrier
public class PlayerInfo
{
public string Name { get; set; } = string.Empty;
public Guid Uuid { get; set; }
public GameMode Mode { get; set; }
}
```
```csharp
// CORRECT: compact constructor for record validation
public record OrderItem(string ProductId, int Quantity, decimal UnitPrice)
{
public OrderItem
{
ArgumentException.ThrowIfNullOrWhiteSpace(ProductId);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(Quantity);
ArgumentOutOfRangeException.ThrowIfNegative(UnitPrice);
}
}
```
### Required Members
```csharp
// CORRECT: required + init enforces initialization without constructor boilerplate
public class ServerConfig
{
public required string Host { get; init; }
public required int Port { get; init; }
public string? Password { get; init; }
}
var config = new ServerConfig { Host = "mc.example.com", Port = 25565 };
```
## Nullable Reference Types
Project has nullable enabled. Follow these rules:
```csharp
// CORRECT: guard at API boundaries with .NET 8 throw helpers
public void Connect(string host, IProtocolHandler handler)
{
ArgumentNullException.ThrowIfNull(handler);
ArgumentException.ThrowIfNullOrEmpty(host);
}
```
```csharp
// WRONG: manual null checks
if (handler == null) throw new ArgumentNullException(nameof(handler));
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentException("Host is required", nameof(host));
```
```csharp
// CORRECT: 'is not null' pattern
if (currentPlayer is not null)
currentPlayer.Update();
```
```csharp
// WRONG: comparison operator for null check
if (currentPlayer != null)
currentPlayer.Update();
```
```csharp
// CORRECT: explicit nullable handling
public Entity? FindEntity(int id)
{
return _entities.TryGetValue(id, out var entity) ? entity : null;
}
// CORRECT: null-coalescing / null-conditional
string name = player?.CustomName ?? player?.Name ?? "Unknown";
// CORRECT: null-forgiving only when proven safe (after ThrowIfNull or equivalent)
string val = GetRequiredValue()!;
// CORRECT: annotate return values
[return: MaybeNull]
public T Find<T>(Predicate<T> match) { }
[MemberNotNull(nameof(_connection))]
private void EnsureConnected() { }
```
```csharp
// WRONG: hiding nullability with null-forgiving
public string GetName(Player? player)
{
return player!.Name; // hides potential NullReferenceException
}
```
## Async / Await
```csharp
// CORRECT: propagate CancellationToken through every async I/O call
public async Task<string> FetchDataAsync(Uri uri, CancellationToken ct = default)
{
using var response = await _httpClient.GetAsync(uri, ct);
return await response.Content.ReadAsStringAsync(ct);
}
```
```csharp
// WRONG: CancellationToken not passed downstream
public async Task<string> FetchDataAsync(Uri uri)
{
using var response = await _httpClient.GetAsync(uri, default);
return await response.Content.ReadAsStringAsync(default);
}
```
```csharp
// CORRECT: ValueTask when result is often available synchronously
public ValueTask<int> GetCachedCountAsync()
{
if (_cache.TryGetValue("count", out int count))
return ValueTask.FromResult(count);
return new ValueTask<int>(LoadCountFromDbAsync());
}
```
```csharp
// WRONG: Task allocates unnecessarily when result is cached
public async Task<int> GetCachedCountAsync()
{
if (_cache.TryGetValue("count", out int count))
return count; // allocates a Task
return await LoadCountFromDbAsync();
}
```
```csharp
// CORRECT: async Task for async event handlers
public async Task HandleEventAsync(GameEvent e, CancellationToken ct)
{
await notificationService.SendAsync(e.PlayerId, ct);
}
```
```csharp
// WRONG: async void — exceptions are unobservable, cannot be awaited
public async void HandleEvent(GameEvent e)
{
await notificationService.SendAsync(e.PlayerId, default);
}
```
```csharp
// CORRECT: await the result
var packet = await reader.ReadPacketAsync(ct);
```
```csharp
// WRONG: .Result / .Wait() causes deadlocks
var packet = reader.ReadPacketAsync(ct).Result;
var packet2 = reader.ReadPacketAsync(ct).GetAwaiter().GetResult();
```
```csharp
// CORRECT: ConfigureAwait(false) in library code
var data = await stream.ReadAsync(buffer, ct).ConfigureAwait(false);
// CORRECT: IAsyncEnumerable for streaming
public async IAsyncEnumerable<ChatMessage> ReadChatStreamAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
while (!ct.IsCancellationRequested)
yield return await _reader.ReadNextAsync(ct);
}
// CORRECT: await using for async disposal
await using var conn = new McConnection(host, port);
```
## LINQ
### Prefer Method Syntax for Most Operations
```csharp
// CORRECT: method syntax for common operations
var onlinePlayers = players
.Where(p => p.IsOnline)
.OrderBy(p => p.Name)
.Select(p => new PlayerListItem(p.Id, p.Name))
.ToList();
```
```csharp
// AVOID: query syntax for simple operations
var onlinePlayers = (
from p in players
where p.IsOnline
orderby p.Name
select new PlayerListItem(p.Id, p.Name)
).ToList();
```
### Use Query Syntax for Joins
```csharp
// CORRECT: query syntax makes joins readable
var results =
from entity in entities
join player in players on entity.OwnerId equals player.Id
where entity.Health > 0
select new { entity.Name, player.Name };
```
```csharp
// AVOID: method syntax for complex joins is hard to read
var results = entities
.Join(players,
e => e.OwnerId,
p => p.Id,
(e, p) => new { e, p })
.Where(x => x.e.Health > 0)
.Select(x => new { x.e.Name, PlayerName = x.p.Name });
```
### Materialize to Avoid Multiple Enumeration
```csharp
// CORRECT: materialize once, iterate many times
var online = players.Where(p => p.IsOnline).ToList();
Console.WriteLine(online.Count);
foreach (var p in online) { }
```
```csharp
// WRONG: enumerates the query twice
var filtered = players.Where(p => p.IsOnline);
Console.WriteLine(filtered.Count()); // first enumeration
foreach (var p in filtered) { } // second enumeration
```
### Use Any() Over Count() > 0
```csharp
// CORRECT: short-circuits on first match
if (entities.Any(e => e.IsHostile))
TriggerAlert();
```
```csharp
// WRONG: counts the entire collection
if (entities.Count(e => e.IsHostile) > 0)
TriggerAlert();
```
### Prefer FirstOrDefault with Null Handling
```csharp
// CORRECT: explicit null handling
var target = players.FirstOrDefault(p => p.Name == name)
?? throw new InvalidOperationException($"Player '{name}' not found");
```
### TryGetNonEnumeratedCount
```csharp
// CORRECT: avoid full enumeration just to get count (.NET 6+)
if (source.TryGetNonEnumeratedCount(out int count))
buffer = new Entity[count];
```
### Avoid LINQ in Hot Paths
```csharp
// CORRECT: manual loop with Span in performance-critical code
Span<byte> data = stackalloc byte[256];
int found = 0;
for (int i = 0; i < data.Length; i++)
if (data[i] == target) found++;
```
```csharp
// AVOID: LINQ allocates enumerators and delegates on hot paths
int found = data.ToArray().Count(b => b == target);
```
## Performance (.NET 8+)
### Span\<T\> / Memory\<T\>
```csharp
// CORRECT: zero-allocation slicing
ReadOnlySpan<char> command = input.AsSpan()[1..]; // skip '/'
// CORRECT: stack-allocated parsing
public static int ParseVarInt(ReadOnlySpan<byte> data, out int bytesRead)
{
int result = 0; bytesRead = 0; byte cur;
do { cur = data[bytesRead]; result |= (cur & 0x7F) << (bytesRead * 7); bytesRead++; }
while ((cur & 0x80) != 0);
return result;
}
```
### FrozenDictionary / FrozenSet (.NET 8)
Build once, read many — ~50% faster lookups than Dictionary.
```csharp
// CORRECT: FrozenDictionary for read-heavy lookup tables (palettes, protocol maps)
using System.Collections.Frozen;
private static readonly FrozenDictionary<int, string> s_blockNames =
new Dictionary<int, string> { [0] = "air", [1] = "stone" }.ToFrozenDictionary();
```
### SearchValues\<T\> (.NET 8)
Hardware-accelerated set search.
```csharp
// CORRECT: precompute once, scan with SIMD
private static readonly SearchValues<char> s_separators = SearchValues.Create(" \t\n\r,;");
int idx = input.AsSpan().IndexOfAny(s_separators);
```
### CompositeFormat (.NET 8)
Parse format string once, reuse.
```csharp
// CORRECT: avoids re-parsing the format string each call
private static readonly CompositeFormat s_logFmt = CompositeFormat.Parse("[{0:HH:mm:ss}] {1}: {2}");
string msg = string.Format(CultureInfo.InvariantCulture, s_logFmt, DateTime.Now, player, text);
```
### ArrayPool / stackalloc
```csharp
// CORRECT: rent from pool for temporary buffers
byte[] buf = ArrayPool<byte>.Shared.Rent(4096);
try { int n = stream.Read(buf.AsSpan(0, 4096)); ProcessPacket(buf.AsSpan(0, n)); }
finally { ArrayPool<byte>.Shared.Return(buf); }
// CORRECT: stackalloc for small, fixed-size buffers (< 512 bytes)
Span<byte> header = stackalloc byte[5];
```
## String Handling
```csharp
// CORRECT: explicit StringComparison — always
bool match = name.Equals("Steve", StringComparison.OrdinalIgnoreCase);
int idx = text.IndexOf("hello", StringComparison.Ordinal);
```
```csharp
// WRONG: allocates a lowered copy
bool match = name.ToLower() == "steve";
```
```csharp
// CORRECT: string.Create for perf-critical formatting
string hex = string.Create(data.Length * 2, data, static (span, bytes) =>
{
for (int i = 0; i < bytes.Length; i++)
bytes[i].TryFormat(span[(i * 2)..], out _, "X2");
});
// CORRECT: StringBuilder for loops
var sb = new StringBuilder(256);
foreach (var item in inventory)
sb.Append(item.Name).Append(" x").Append(item.Count).AppendLine();
```
```csharp
// WRONG: O(n²) string concatenation in loop
string combined = "";
foreach (var s in items) combined += s + ", ";
```
## Collections — Choosing the Right Type
| Scenario | Type | Notes |
|---|---|---|
| General key-value | `Dictionary<K,V>` | O(1) lookup |
| Build once, read many | `FrozenDictionary<K,V>` | .NET 8+; faster reads |
| Thread-safe | `ConcurrentDictionary<K,V>` | Lock-free reads |
| Immutable snapshots | `ImmutableDictionary<K,V>` | Persistent structure |
| Membership test | `HashSet<T>` / `FrozenSet<T>` | FrozenSet for static |
| Priority queue | `PriorityQueue<E,P>` | .NET 6+ |
| Synchronization | `System.Threading.Lock` | C# 13; prefer over `lock(obj)` |
| Producer-consumer | `Channel<T>` | Over `BlockingCollection<T>` |
| Temp buffer | `ArrayPool<T>` / `stackalloc` | Zero/low alloc |
## Error Handling
```csharp
// CORRECT: Try* pattern for expected failures
if (int.TryParse(input, out int value)) ProcessValue(value);
if (_registry.TryGetValue(packetId, out var handler)) handler.Invoke(data);
```
```csharp
// WRONG: using exceptions for control flow
try { return dict[key]; }
catch (KeyNotFoundException) { return null; } // use TryGetValue
```
```csharp
// CORRECT: exception filters (catch-when)
try { await ConnectAsync(ct); }
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused)
{
LogToConsole("Connection refused, retrying...");
}
```
```csharp
// CORRECT: throw helpers (smaller IL, better inlining)
ArgumentNullException.ThrowIfNull(handler);
ArgumentOutOfRangeException.ThrowIfNegative(timeout);
ArgumentOutOfRangeException.ThrowIfGreaterThan(timeout, MaxTimeout);
ObjectDisposedException.ThrowIf(_disposed, this);
```
```csharp
// WRONG: generic exceptions
throw new Exception($"Entity {id} not found");
```
```csharp
// CORRECT: specific, meaningful exception types
throw new EntityNotFoundException(id);
// or use null-coalescing with throw
return await FindEntityAsync(id, ct)
?? throw new EntityNotFoundException(id);
```
```csharp
// AVOID: catching Exception without filtering
try { DoWork(); }
catch (Exception) { /* swallowed */ }
```
## Warning Suppression
```csharp
// CORRECT: fix the warning by handling null properly
public string GetDisplayName(Player? player)
{
return player?.DisplayName ?? "Unknown";
}
```
```csharp
// WRONG: suppressing nullable warning with pragma
#pragma warning disable CS8602
public string GetDisplayName(Player? player)
{
return player.DisplayName; // NullReferenceException at runtime
}
#pragma warning restore CS8602
```
```csharp
// WRONG: suppressing with attribute
[SuppressMessage("Usage", "CA1062:Validate arguments of public methods")]
public void Process(Packet packet)
{
// missing null check
}
```
Project-wide `.editorconfig` is the only acceptable place for warning policy:
```text
# .editorconfig - project-wide policy decisions only
dotnet_diagnostic.CA2007.severity = none
```
## Resource Management
```csharp
// CORRECT: using declaration — disposed at end of scope
using var stream = new FileStream(path, FileMode.Open);
using var reader = new StreamReader(stream);
// CORRECT: IAsyncDisposable
await using var conn = await CreateConnectionAsync();
```
```csharp
// CORRECT: Dispose pattern
public class PacketReader : IDisposable
{
private Stream? _stream;
private bool _disposed;
public void Dispose()
{
if (_disposed) return;
_stream?.Dispose(); _stream = null; _disposed = true;
}
}
```
## Security
```csharp
// CORRECT: secure random for tokens
byte[] token = RandomNumberGenerator.GetBytes(32);
// CORRECT: constant-time comparison for secrets
bool valid = CryptographicOperations.FixedTimeEquals(expected, actual);
// CORRECT: validate external input
if (Uri.TryCreate(userInput, UriKind.Absolute, out var uri)
&& uri.Scheme is "http" or "https")
await FetchAsync(uri);
```
```csharp
// WRONG: predictable random for security-sensitive values
var rng = new Random();
// WRONG: timing side-channel on secret comparison
bool eq = secret1.SequenceEqual(secret2);
```
## Miscellaneous Idioms
```csharp
// CORRECT: var when type is obvious from RHS
var entities = new Dictionary<int, Entity>();
var timer = Stopwatch.StartNew();
// CORRECT: explicit type when var would be unclear
Stream responseStream = GetResponse();
int count = items.Count;
// CORRECT: expression-bodied members for one-liners
public override string ToString() => $"[{X}, {Y}, {Z}]";
public bool IsAlive => Health > 0;
// CORRECT: discards for unused values
_ = int.TryParse(s, out int result);
(_, int y, _) = GetCoordinates();
// CORRECT: nameof for resilient refactoring (unbound generics in C# 14)
throw new ArgumentException("Invalid value", nameof(packetId));
LogToConsole($"{nameof(AutoEat)}: eating {item.Name}");
string typeName = nameof(Dictionary<,>); // "Dictionary"
// CORRECT: static lambdas prevent accidental closure allocations
list.Sort(static (a, b) => a.Id.CompareTo(b.Id));
// CORRECT: index/range operators
var last = items[^1];
var slice = data[3..^1];
// CORRECT: tuple deconstruction
var (x, y, z) = GetPosition();
// CORRECT: string interpolation with alignment and format specifiers
LogToConsole($"Health: {health,6:F1} | Hunger: {hunger,6:F1}");
```

View file

@ -0,0 +1,166 @@
---
name: csharp-dotnet-cli-optimization
description: >-
Use when diagnosing or optimizing generic C#/.NET performance, GC pressure,
allocations, heap or stack usage, LINQ overhead, boxing, Span/Memory,
stackalloc, pooling, or hot-path code with CLI-first tools such as
dotnet-counters, dotnet-trace, dotnet-stack, dotnet-gcdump, dotnet-dump, or
BenchmarkDotNet.
metadata:
category: technique
triggers:
- dotnet-counters
- dotnet-trace
- dotnet-dump
- dotnet-gcdump
- dotnet-stack
- benchmarkdotnet
- allocations
- gc pressure
- memory leak
- hot path
- linq
- stackalloc
- span
- memory
- boxing
- heap
- stack
- latency
- throughput
- slow
- hang
- deadlock
---
# C#/.NET CLI Optimization
CLI-first guidance for generic C# 14 / .NET 10 performance work.
Use the references on demand:
- Read [references/memory-model-gc.md](references/memory-model-gc.md) for stack vs heap, generations, LOH, pinning, server vs workstation GC, and GC tuning limits.
- Read [references/code-patterns.md](references/code-patterns.md) for LINQ, Span/Memory, stackalloc, structs, boxing, pooling, strings, and analyzer-backed code patterns.
- Read [references/README.md](references/README.md) for dated sources and freshness notes.
## When to Use
- A .NET process is slow, allocation-heavy, CPU-heavy, or memory-hungry
- A live process appears stuck, hung, or deadlocked
- The user asks how heap, stack, GC, boxing, or LINQ overhead actually works in .NET
- The user wants concrete bad vs good code patterns after measurement has identified a hot path
- The task needs a decision between counters, traces, stacks, GC dumps, dumps, or a benchmark
**NOT for:**
- ASP.NET Core, EF Core, MAUI, Orleans, Unity, Avalonia, WPF, WinForms, Blazor, or other framework-specific playbooks
- Visual Studio, Rider, VS Code, PerfView, speedscope, or any GUI-first workflow
- speculative rewrites such as "replace everything with Span" before measurement
## Iron Rule
ALWAYS measure first, change second, and re-measure third.
NEVER claim an optimization without before/after evidence from the same scenario.
| Rationalization | Reality |
|---|---|
| "This is obviously slow" | The runtime, JIT, and libraries often invalidate intuition. |
| "struct means stack" | Value types are stored inline. They are not "always on the stack". |
| "All LINQ is slow" | .NET 10 improved many LINQ paths. Measure before rewriting. |
| "GC.Collect will fix it" | Forced collection usually treats symptoms, not cause. |
## Investigation Order
1. Use `dotnet-counters` for live triage.
2. If the process is stuck, capture `dotnet-stack` immediately.
3. If CPU or allocation hot paths matter, collect `dotnet-trace`.
4. If heap growth matters more than call paths, collect `dotnet-gcdump`.
5. If you need SOS heap inspection or a postmortem, collect `dotnet-dump`.
6. Only after live evidence points to a candidate routine, apply patterns from the reference docs.
7. If the change is truly local and isolated, use BenchmarkDotNet to compare implementations.
8. Re-run the original live capture to prove the real workload improved.
## Which Reference to Load
| User question | Read first |
|---|---|
| "How do stack and heap really work in .NET?" | `references/memory-model-gc.md` |
| "Why is GC pausing or why is LOH churn hurting us?" | `references/memory-model-gc.md` |
| "How should I optimize this LINQ?" | `references/code-patterns.md` |
| "Can I move this to the stack with stackalloc or Span?" | `references/code-patterns.md` |
| "Should this be a struct, ref struct, readonly struct, or class?" | `references/code-patterns.md` and `references/memory-model-gc.md` |
| "Why is this boxing?" | `references/code-patterns.md` |
## Tool Selection
| Question | Tool | What it answers | Typical next step |
|---|---|---|---|
| Is the live process allocating, GCing, or saturating CPU? | `dotnet-counters` | Live counters and trend direction | Capture a trace or GC dump if suspicious |
| Is the process hung or deadlocked right now? | `dotnet-stack` | Current managed stack snapshot | Collect a dump if you need deeper postmortem evidence |
| Which call paths consume CPU or allocate heavily? | `dotnet-trace` | Sampled execution and runtime events | Confirm hot paths, then isolate code |
| Which object types dominate managed heap usage? | `dotnet-gcdump` | Heap composition and type totals | Decide whether to redesign lifetimes or collect a full dump |
| Do I need SOS heap inspection or thread state? | `dotnet-dump` | Full dump plus CLI analysis | Run `analyze -c` commands |
| Did a code change improve one isolated routine? | BenchmarkDotNet | Reproducible microbenchmark comparison | Re-run live diagnostics in the real scenario |
## Pattern Guardrails
- Do not answer "put it on the stack" as a blanket goal. Explain lifetime, copies, boxing, and escape rules instead.
- Do not suggest `stackalloc` for unbounded sizes, large buffers, or loop-carried allocations.
- Do not recommend `Span<T>` for data that must cross `await`, escape to the heap, or live in object fields. Switch to `Memory<T>` or `ReadOnlyMemory<T>` for that.
- Do not recommend converting every `class` to a `struct`. Large, mutable, identity-bearing, or frequently boxed types often get worse.
- Do not blanket-rewrite LINQ to loops. Use analyzer-backed fixes first, and remember .NET 10 substantially improved many LINQ paths.
- Do not recommend pooling without ownership rules. Returned pooled arrays must not be reused by the caller.
- Do not recommend `GC.Collect()` except for rare, justified lifecycle boundaries, and only with measurement.
## Analyzer Radar
When performance diagnostics point to code patterns rather than runtime configuration, consult the current performance analyzers, especially:
- `CA1826`, `CA1827`, `CA1829`, `CA1836`, `CA1851`, `CA1860` for LINQ and enumeration
- `CA1845`, `CA1846`, `CA1858` for string and span-friendly APIs
- `CA1834`, `CA1865-CA1867` for `StringBuilder` char overloads
- `CA1870` for cached `SearchValues<T>`
These rules are clues, not goals. Apply them where the measured hot path justifies it.
## Minimal Commands
```bash
dnx dotnet-counters monitor --process-id <PID>
dotnet-counters monitor -p <PID> --counters System.Runtime
dotnet-stack report -p <PID>
dotnet-trace collect -p <PID> --duration 00:00:30
dotnet-trace report <trace.nettrace> topN
dotnet-gcdump collect -p <PID>
dotnet-gcdump report <file.gcdump>
dotnet-dump collect -p <PID> --type Heap
dotnet-dump analyze <dump> -c "dumpheap -stat" -c "exit"
```
Minimal BenchmarkDotNet pattern:
```csharp
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class CandidateBench
{
[Benchmark(Baseline = true)]
public int Original() => OriginalImpl();
[Benchmark]
public int Candidate() => CandidateImpl();
}
```
```bash
dotnet run -c Release
```
## Output
When using this skill, report:
- the measured symptom and the evidence used to identify it
- the chosen tool or code pattern and why it fits this bottleneck
- the relevant tradeoff, such as allocation vs copy cost, deferred vs eager execution, or stack vs pool
- the before/after result, or say explicitly if the recommendation is still unverified

View file

@ -0,0 +1,68 @@
---
description: Dated source ledger for csharp-dotnet-cli-optimization.
metadata:
tags: [sources, diagnostics, gc, linq, span, stackalloc, boxing]
---
# Sources
## Current primary sources
- [dotnet-counters](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters), Microsoft Learn, updated `2025-10-02`
- Canonical CLI docs for live counters, `monitor`, `collect`, and `dnx` one-shot execution on .NET 10.0.100+.
- [dotnet-trace](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-trace), Microsoft Learn, updated `2026-03-20`
- Canonical CLI docs for `collect`, `report`, and the preview `collect-linux` path plus its limits.
- [dotnet-dump](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-dump), Microsoft Learn, updated `2026-03-04`
- Canonical CLI docs for dump collection, dump types, and `analyze -c`.
- [dotnet-gcdump](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-gcdump), Microsoft Learn, updated `2025-12-17`
- Canonical CLI docs for GC dump collection, `report`, and the induced full Gen 2 GC caveat.
- [Fundamentals of garbage collection](https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals), Microsoft Learn, updated `2025-10-22`
- Current official overview of generations, allocation, and managed heap behavior.
- [Runtime configuration options for garbage collection](https://learn.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector), Microsoft Learn, updated `2025-11-22`
- Current official source for server vs workstation GC, background GC, heap limits, LOH threshold, and modern GC configuration behavior.
- [stackalloc expression](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc), Microsoft Learn, updated `2026-01-24`
- Current official guidance for stack allocation limits, loop avoidance, initialization, and Span-based usage.
- [ref struct types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/ref-struct), Microsoft Learn, updated `2026-01-20`
- Current official guidance for stack-only semantics and escape restrictions.
- [Structure types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct), Microsoft Learn, updated `2026-01-14`
- Current official source for readonly structs, pass-by-reference guidance, and boxing conversions.
- [Value types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/value-types), Microsoft Learn, updated `2026-01-20`
- Current official source for copy semantics and inline storage behavior.
- [Boxing and Unboxing](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/types/boxing-and-unboxing), Microsoft Learn, updated `2025-10-13`
- Current official source for boxing semantics and cost.
- [Memory<T> and Span<T> usage guidelines](https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/memory-t-usage-guidelines), Microsoft Learn, updated `2025-04-11`
- Current official guidance for choosing `Span<T>` vs `Memory<T>` and ownership rules.
- [Lambda expressions](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/lambda-expressions), Microsoft Learn, updated `2026-01-24`
- Current official source for capture semantics and `static` lambdas.
- [What's new in C# 14](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-14), Microsoft Learn, updated `2025-11-19`
- Current official confirmation of first-class span conversions in C# 14.
- [Performance rules](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/performance-warnings), Microsoft Learn, updated `2025-10-29`
- Current official index of analyzer-backed performance rules, including `CA1870`.
- [Performance Improvements in .NET 10](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/), Stephen Toub, published `2025-09-10`
- High-trust expert source showing real .NET 10 runtime and LINQ improvements. Use it to avoid stale folklore such as "all LINQ is slow".
## Specific analyzer pages used for code-pattern guidance
- [CA1827](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1827), updated `2023-11-14`
- [CA1845](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1845), updated `2024-11-12`
- [CA1846](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1846), updated `2023-12-16`
- [CA1851](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1851), updated `2023-11-14`
- [CA1858](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1858), current official analyzer page
- [CA1860](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1860), current official analyzer page
## Older but still canonical sources used cautiously
- [Reduce memory allocations using new C# features](https://learn.microsoft.com/en-us/dotnet/csharp/advanced-topics/performance/), Microsoft Learn, updated `2023-10-17`
- Still useful for `ref`, `in`, readonly struct, and copy-avoidance guidance, but older than the core 2025-2026 docs.
- [Intermediate materialization](https://learn.microsoft.com/en-us/dotnet/standard/linq/intermediate-materialization), Microsoft Learn, updated `2022-09-02`
- Still canonical for LINQ materialization semantics.
- [Deferred execution and lazy evaluation](https://learn.microsoft.com/en-us/dotnet/standard/linq/deferred-execution-lazy-evaluation), Microsoft Learn, updated `2022-09-29`
- Still canonical for LINQ deferred-execution semantics.
- [dotnet-stack](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-stack), Microsoft Learn, updated `2023-03-14`
- Used only for narrow stack-snapshot guidance because the page is stale compared to the other diagnostics docs.
## Explicit exclusions
- Framework-specific tutorials were intentionally excluded.
- GUI-first analysis flows were intentionally excluded.
- MCC-specific paths, code, and hot-path examples were intentionally excluded.

View file

@ -0,0 +1,381 @@
---
description: Code-level performance patterns for csharp-dotnet-cli-optimization.
metadata:
tags: [linq, span, stackalloc, boxing, pooling, strings, analyzers]
---
# Code Patterns
Use this reference after a profile or benchmark identifies a hot path. Do not apply these patterns speculatively.
## Table Of Contents
- [LINQ And Enumeration](#linq-and-enumeration)
- [Stack Allocation, Span, And Memory](#stack-allocation-span-and-memory)
- [Structs, Boxing, And Copies](#structs-boxing-and-copies)
- [Buffer Reuse And Advanced Helpers](#buffer-reuse-and-advanced-helpers)
- [Strings](#strings)
- [What Not To Suggest](#what-not-to-suggest)
## LINQ And Enumeration
### Property or indexer over LINQ when the concrete collection is known
Wrong:
```csharp
if (items.Count() > 0)
{
return items.First();
}
```
Better:
```csharp
if (items.Count > 0)
{
return items[0];
}
```
Use `Count`, `Length`, `IsEmpty`, or an indexer when you already have a concrete collection with that API. Relevant analyzers: `CA1826`, `CA1829`, `CA1836`, `CA1860`.
### `Any()` over `Count() > 0` when all you know is `IEnumerable<T>`
Wrong:
```csharp
if (source.Count() != 0)
{
Process(source);
}
```
Better:
```csharp
if (source.Any())
{
Process(source);
}
```
Relevant analyzer: `CA1827`.
### Avoid multiple enumeration of deferred queries
Wrong:
```csharp
var query = source.Where(Filter);
return query.Count() + query.Last().Id;
```
Better:
```csharp
var materialized = source.Where(Filter).ToArray();
return materialized.Length + materialized[^1].Id;
```
Materialize once only when you truly need multiple passes or random access and can afford the extra memory. Relevant analyzer: `CA1851`.
### Avoid premature materialization
Wrong:
```csharp
var projected = source.ToList().Select(Map);
```
Better:
```csharp
var projected = source.Select(Map);
```
Keep deferred execution unless you need a snapshot, repeated traversal, indexing, or a boundary between expensive stages.
### Do not blanket-rewrite LINQ to loops
- .NET 10 improved many LINQ operations substantially.
- Start with analyzer-backed fixes and measurement.
- Replace LINQ with hand-written loops only when a benchmark or trace shows that the remaining cost matters.
### Use `TryGetNonEnumeratedCount` when count is optional
```csharp
if (source.TryGetNonEnumeratedCount(out int count))
{
LogCount(count);
}
```
This avoids forcing enumeration when the underlying type already knows its size.
## Stack Allocation, Span, And Memory
### `stackalloc` only for small, bounded, temporary buffers
Wrong:
```csharp
for (int i = 0; i < items.Length; i++)
{
Span<byte> buffer = stackalloc byte[4096];
Use(buffer);
}
```
Better:
```csharp
Span<byte> buffer = stackalloc byte[256];
for (int i = 0; i < items.Length; i++)
{
buffer.Clear();
Use(buffer);
}
```
Guidance:
- keep sizes conservative
- avoid `stackalloc` inside loops
- initialize the memory before use
- fall back to heap or pooling for larger or variable-sized buffers
### Prefer span-based APIs over substring copies
Wrong:
```csharp
int.TryParse(line.Substring(7), out int value);
```
Better:
```csharp
int.TryParse(line.AsSpan(7), out int value);
```
Relevant analyzers: `CA1845`, `CA1846`.
### Use `Span<T>` for sync work and `Memory<T>` for async or heap-stored state
Wrong:
```csharp
// Wrong: Span<T> cannot cross await safely.
public async Task<int> ReadAsync(Span<byte> buffer)
{
await socket.ReceiveAsync(buffer);
return buffer[0];
}
```
Better:
```csharp
public async Task<int> ReadAsync(Memory<byte> buffer)
{
await socket.ReceiveAsync(buffer);
return buffer.Span[0];
}
```
`Span<T>` is stack-only. If the lifetime crosses `await`, callbacks, or object storage, move to `Memory<T>`.
### `ref struct` is for stack-bound wrappers, not a general optimization badge
- Use `ref struct` when the type itself contains spans or must never escape to the heap.
- Do not use it if you need arrays of that type, boxing, interface conversions, or heap fields.
## Structs, Boxing, And Copies
### Use `readonly struct` or `readonly record struct` for small immutable values
Wrong:
```csharp
public struct Measurement
{
public double A;
public double B;
public void Normalize() => A /= B;
}
```
Better:
```csharp
public readonly record struct Measurement(double A, double B);
```
Prefer value types for small, copyable, data-only values. Avoid large, mutable structs.
### Pass large structs by `in`
Wrong:
```csharp
double Distance(Vector4 value) => value.X + value.Y + value.Z + value.W;
```
Better:
```csharp
double Distance(in Vector4 value) => value.X + value.Y + value.Z + value.W;
```
This avoids copying large struct values on each call.
### Avoid boxing in hot paths
Wrong:
```csharp
object boxed = valueStruct;
```
Wrong:
```csharp
IFormattable f = valueStruct;
```
Better:
```csharp
Use(in valueStruct);
```
Boxing allocates a heap object and copies the value. Interface conversions can box too.
### Mark readonly members on structs
- Non-readonly instance members on a readonly receiver can trigger defensive copies.
- Mark the whole struct `readonly` when possible, or mark readonly members explicitly.
## Buffer Reuse And Advanced Helpers
### Use `ArrayPool<T>` when the buffer is too large or variable for `stackalloc`
Wrong:
```csharp
byte[] temp = new byte[inputLength];
```
Better:
```csharp
byte[] temp = ArrayPool<byte>.Shared.Rent(inputLength);
try
{
Use(temp);
}
finally
{
ArrayPool<byte>.Shared.Return(temp);
}
```
Rules:
- return to the same pool once
- never use the buffer after return
- rented arrays may be larger than requested
- rented arrays are not guaranteed to be zeroed
### Prevent accidental closure capture
Wrong:
```csharp
return values.Select(v => v * 2).ToArray();
```
Better when no capture is needed:
```csharp
return values.Select(static v => v * 2).ToArray();
```
Use `static` lambdas or static local functions to prevent capture when the delegate does not need outer state.
### Cache `SearchValues<T>` for repeated searches
Wrong:
```csharp
int index = text.IndexOfAny(":/?&=".AsSpan());
```
Better:
```csharp
private static readonly SearchValues<char> s_delims =
SearchValues.Create(":/?&=".AsSpan());
```
```csharp
int index = text.IndexOfAny(s_delims);
```
Relevant analyzer: `CA1870`.
### `CollectionsMarshal.AsSpan` is advanced and ownership-sensitive
```csharp
Span<int> span = CollectionsMarshal.AsSpan(list);
```
Use this only when:
- you own the `List<T>`
- you will not add or remove items while the span is in use
- a measured hot path justifies bypassing normal list APIs
## Strings
### `StartsWith` over `IndexOf(...) == 0`
Wrong:
```csharp
return text.IndexOf("abc", StringComparison.Ordinal) == 0;
```
Better:
```csharp
return text.StartsWith("abc", StringComparison.Ordinal);
```
Relevant analyzer: `CA1858`.
### `Append(char)` over `Append("x")`
Wrong:
```csharp
builder.Append("]");
```
Better:
```csharp
builder.Append(']');
```
Relevant analyzers: `CA1834`, `CA1865-CA1867`.
## What Not To Suggest
- Do not suggest unsafe code first.
- Do not suggest pooling tiny objects by default.
- Do not suggest `stackalloc` because "heap bad, stack good".
- Do not suggest converting APIs to `Span<T>` if the lifetime model does not fit.
- Do not suggest loop rewrites without a profile or benchmark showing LINQ still matters after simpler fixes.

View file

@ -0,0 +1,117 @@
---
description: CLR memory model and GC guidance for csharp-dotnet-cli-optimization.
metadata:
tags: [clr, gc, heap, stack, loh, memory-model]
---
# CLR Memory Model And GC
Use this reference when the user asks why allocations, boxing, heap growth, GC pauses, or stack-based techniques behave the way they do.
## Core Model
- Reference types allocate objects on the managed heap. Local variables and fields hold references to those objects.
- Value types store their data directly. A value type local is often stored in stack storage, but value types also live inline inside object fields and array elements.
- "`struct` means stack" is false. The useful distinction is inline storage plus copy semantics, not "stack forever".
- Boxing converts a value type to `object` or an interface by allocating a new heap object and copying the value into it.
- `ref struct` types, including `Span<T>` and `ReadOnlySpan<T>`, are stack-constrained wrappers that can't escape to the managed heap.
- `Memory<T>` and `ReadOnlyMemory<T>` are the heap-storable counterparts when data must live across `await`, callbacks, or object fields.
## How The GC Works
- The GC is generational: gen0 for young objects, gen1 as a buffer, gen2 for long-lived survivors.
- The large object heap (LOH) is used for allocations at or above 85,000 bytes by default.
- Background GC is enabled by default. It reduces pause impact for full collections but does not make them free.
- Server GC and workstation GC are process-level choices. The defaults are usually right unless measurement says otherwise.
- On modern 64-bit Windows and Linux, the GC internally uses regions, but the optimization model for application code is still about generations, allocation rate, survivor rate, LOH churn, and pinning.
## What Usually Makes GC Expensive
- High allocation rate on hot paths
- Objects surviving long enough to promote into older generations
- Large transient allocations that churn the LOH
- Excessive pinning that increases fragmentation
- Finalizers on objects that should have been deterministic `Dispose` calls instead
## Wrong vs Better
| Wrong | Better | Why |
|---|---|---|
| Assume a `struct` is always stack allocated | Explain whether it will be copied, boxed, stored inline, or escape | That is what actually drives cost |
| Allocate large temporary arrays repeatedly | Reuse, pool, or redesign the algorithm if measurement shows LOH churn | LOH allocations are cleared and collected with gen2 work |
| Call `GC.Collect()` to "fix" memory pressure | Lower allocation rate and object lifetime first | Forced GC usually adds pause time and hides the real problem |
| Pin many buffers for long periods | Minimize pin count and pin duration | Pinning can fragment the heap |
| Use finalizers for routine cleanup | Use `IDisposable`, `using`, and `SafeHandle` for unmanaged resources | Finalization is slower and delays reclamation |
## GC Configuration Rules
- Treat GC configuration changes as process-wide tuning, not local fixes.
- Prefer runtime defaults unless counters and traces show a clear reason to change them.
- Choose server GC for throughput-oriented workloads only after measurement.
- Use low-latency modes sparingly and for bounded windows. They reduce GC intrusiveness by letting memory grow and can increase fragmentation.
- If you are tuning in containers or hard memory limits, treat heap hard-limit settings as operational controls, not code-level optimizations.
## Bad vs Good Examples
Bad:
```csharp
for (int i = 0; i < 10_000; i++)
{
DoWork(new byte[200_000]);
}
GC.Collect();
```
Better:
```csharp
byte[] buffer = ArrayPool<byte>.Shared.Rent(200_000);
try
{
for (int i = 0; i < 10_000; i++)
{
DoWork(buffer);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
```
Bad:
```csharp
public sealed class NativeThing
{
~NativeThing() => ReleaseHandle();
}
```
Better:
```csharp
public sealed class NativeThing : IDisposable
{
public void Dispose()
{
ReleaseHandle();
GC.SuppressFinalize(this);
}
}
```
## Practical Heuristics
- If counters show rising allocation rate and frequent gen0 collections, start by eliminating short-lived allocations.
- If gen2 collections or LOH size are the problem, look for survivor growth, pinned buffers, and large transient objects.
- If a change turns classes into structs, verify both allocation wins and copy costs.
- If the process is memory-constrained, inspect runtime GC settings before changing code blindly.
## What Not To Claim
- Do not claim that moving code to `struct` always reduces memory.
- Do not claim that stack allocation is always faster than pooling.
- Do not claim that background GC removes pause concerns.
- Do not claim that the GC is the problem unless counters, traces, or dumps support that diagnosis.

View file

@ -0,0 +1,267 @@
---
name: csharp-optimization
description: >-
Use when optimizing C# code in MCC, reducing GC pressure, profiling hot paths,
fixing latency spikes, or reviewing code for allocation or throughput issues.
metadata:
category: technique
triggers: performance, allocations, GC, hot path, latency, throughput,
memory pressure, optimize, slow, freeze, lag spike, packet processing speed
---
# C# Performance Optimization for MCC
Hands-on optimization recipes for Minecraft Console Client hot paths.
Complements `csharp-best-practices` (conventions) with measurement-driven
performance work.
## When to Use
- Profiling or reducing GC pressure in a running MCC session
- Optimizing per-packet code (`Protocol18.HandlePacket`, `DataTypes.ReadNext*`)
- Optimizing per-tick code (`PlayerPhysics.Tick`, `CollisionDetector.Collide`)
- Speeding up chunk decoding (`Protocol18Terrain.ProcessChunkColumnData`)
- Improving A* pathfinding (`Movement.CalculatePath`)
- Reviewing any code change for allocation or throughput regressions
**NOT for:**
- Login, config parsing, or one-shot command handlers (prefer clarity there)
- Style/convention questions (use `csharp-best-practices` instead)
---
## Iron Rule: Measure First
**NEVER optimize without profiling data.**
Guessing which code is slow is wrong more often than right. Measure, change,
re-measure. If you cannot show a before/after number, the optimization is not
justified.
| Rationalization | Reality |
|-----------------|---------|
| "This is obviously slow" | Obvious to you is not obvious to the JIT. Measure. |
| "I'll profile later" | Later never comes. Profile now or don't optimize. |
| "It's just one allocation" | On a 20 TPS tick, one allocation = 20 per second = GC pressure. Measure. |
| "AggressiveInlining everywhere" | The JIT already inlines small methods. Prove it helps before adding. |
---
## MCC Hot-Path Map
Know which code runs at which frequency before deciding where to invest:
| Frequency | Key paths (actual files) | Priority |
|---|---|---|
| Per-packet (100s/sec) | `Protocol/Handlers/Protocol18.cs` HandlePacket, `Protocol/Handlers/DataTypes.cs` ReadNext* | **High** |
| Per-tick (20/sec) | `Physics/PlayerPhysics.cs` Tick, `Physics/CollisionDetector.cs` Collide, ChatBot `Update()` | **High** |
| Per-chunk-load | `Protocol/Handlers/Protocol18Terrain.cs` ProcessChunkColumnData, ReadBlockStatesField | Medium |
| Per-pathfind | `Mapping/Movement.cs` CalculatePath (A*) | Medium |
| Per-connection | Login, registry sync, config | Low |
| Per-user-action | Commands, chat | Low |
---
## Profiling Recipes
### 1. Live GC monitoring
```bash
dotnet-counters ps # find MinecraftClient PID
dotnet-counters monitor --process-id <PID> \
--counters System.Runtime[gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,alloc-rate]
```
Healthy idle MCC: near-zero Gen-1/Gen-2 collections. Frequent Gen-0 during idle
means a hot-path allocation needs attention.
### 2. Allocation tracking
```bash
dotnet-trace collect --process-id <PID> \
--providers Microsoft-Windows-DotNETRuntime:0x1:5
```
Open `.nettrace` in PerfView to find top-allocated types and call stacks.
### 3. Isolated benchmarks (BenchmarkDotNet)
Extract the hot method, add `[MemoryDiagnoser]`. Key columns: **Mean**,
**Allocated**, **Gen0**.
---
## Allocation Reduction (Highest Impact)
Reducing GC pressure directly reduces latency spikes in a long-running client.
### Pattern: Reuse per-tick buffers
```csharp
// BEFORE: new List every tick (20 allocations/sec)
var result = new List<Aabb>();
// AFTER: thread-local reuse (0 allocations/sec)
[ThreadStatic] private static List<Aabb>? t_buf;
var result = t_buf ??= new List<Aabb>(64);
result.Clear();
```
`[ThreadStatic]` works when single-threaded and non-reentrant (physics tick).
If reentrant: use `ObjectPool<T>`. If cross-thread: use `ArrayPool<T>`.
### Pattern: stackalloc for small fixed buffers
MCC already does this in `DataTypes.cs` for endian-swapped reads:
```csharp
Span<byte> rawValue = stackalloc byte[8];
for (int i = 7; i >= 0; --i) rawValue[i] = cache.Dequeue();
return BitConverter.ToDouble(rawValue);
```
Rules: under 512 bytes, known size at compile time, never inside loops or recursion.
### Pattern: Span slicing instead of array copies
```csharp
// BEFORE: allocates
byte[] sub = new byte[length];
Array.Copy(source, offset, sub, 0, length);
// AFTER: zero-copy
ReadOnlySpan<byte> sub = source.AsSpan(offset, length);
```
Critical in packet parsing where many fields are sliced from one buffer.
---
## Hot-Path Tuning
### MethodImpl attributes
MCC uses `[MethodImpl]` on its hottest paths. Match the attribute to the method:
| Attribute | When | MCC examples |
|---|---|---|
| `AggressiveInlining` | Tiny methods (< ~32 bytes IL), called millions of times | `Vec3d.Add`, `Aabb.Intersects`, `Chunk.SetWithoutCheck` |
| `AggressiveOptimization` | Larger critical-path methods | `ReadBlockStatesField`, `ProcessChunkColumnData` |
| Both | Medium methods, very high frequency | `DataTypes.ReadNextVarInt`, `ReadDataReverse` |
| Neither | Infrequent code | Login, config, commands |
**Do not scatter `AggressiveInlining` without profiling evidence.** The JIT
already inlines small methods.
### BinaryPrimitives over BitConverter
```csharp
// BEFORE: manual endian swap
(buf[0], buf[3]) = (buf[3], buf[0]);
int val = BitConverter.ToInt32(buf);
// AFTER: direct big-endian read, no branch
int val = BinaryPrimitives.ReadInt32BigEndian(buf);
```
### MemoryMarshal for bulk reads
Already used in chunk decoding for zero-copy packed-long reads:
```csharp
ReadOnlySpan<long> longs = MemoryMarshal.Cast<byte, long>(entryData);
```
---
## Data Structure Selection
### Frozen collections for palettes
Palette maps are built once and read millions of times. `FrozenDictionary`
gives ~50% faster reads than `Dictionary`:
```csharp
private static readonly FrozenDictionary<int, Material> s_palette =
new Dictionary<int, Material> { ... }.ToFrozenDictionary();
```
Apply to: `BlockPalettes/*.cs`, `EntityPalettes/*.cs`, `ItemPalettes/*.cs`,
`PacketPalettes/*.cs`, any `static readonly Dictionary` populated once.
### PriorityQueue for A*
`Movement.cs` has a custom `BinaryHeap`. The built-in `PriorityQueue<TElement,
TPriority>` (.NET 6+) is well-optimized and avoids maintenance burden.
### ConcurrentDictionary sizing
Pre-size `World.chunks` to avoid rehashing:
```csharp
new ConcurrentDictionary<(int, int), ChunkColumn>(
concurrencyLevel: Environment.ProcessorCount, capacity: 1024);
```
---
## Threading
### Minimize lock scope
Copy data out under the lock, process outside:
```csharp
List<Item> snapshot;
lock (_lock) { snapshot = [.. _items]; }
foreach (var item in snapshot) ExpensiveProcess(item);
```
### Batch InvokeOnMainThread
Each `InvokeOnMainThread()` call blocks until the main thread runs it.
In loops, batch into a single call:
```csharp
handler.InvokeOnMainThread(() =>
{
foreach (var entity in entities) UpdateEntity(entity);
});
```
### Channel\<T\> over BlockingCollection\<T\>
Lower overhead, async-friendly:
```csharp
var ch = Channel.CreateUnbounded<(int Id, Memory<byte> Data)>(
new UnboundedChannelOptions { SingleReader = true });
```
---
## Common Optimization Anti-Patterns
These are things agents (and humans) rationalize doing. Every one of them
makes performance worse or wastes effort.
| Anti-pattern | Why it's wrong |
|---|---|
| Adding `AggressiveInlining` to large methods | Bloats call sites, causes more cache misses, makes code *slower* |
| Optimizing login/config code | Runs once per session; clarity matters more than speed |
| Using `ConcurrentDictionary` where a plain `Dictionary` + lock suffices | Concurrent overhead on uncontested paths costs more than a lock |
| Replacing LINQ with manual loops on cold paths | No measurable gain, worse readability |
| Caching mutable state to avoid re-reads | Stale cache bugs are harder to diagnose than the perf hit |
| `Task.Result` / `.Wait()` on hot paths | Deadlock risk and thread-pool starvation |
---
## Pre-Commit Checklist
ALWAYS verify before submitting a performance change:
- [ ] Hot path identified with profiling data, not guesswork
- [ ] Before/after measurements recorded (allocation count, throughput, or latency)
- [ ] No new allocations inside per-tick or per-packet methods
- [ ] `[MethodImpl]` attributes match method call frequency and IL size
- [ ] Frozen collections used for any static lookup table
- [ ] Lock scopes contain no I/O or expensive work
- [ ] No `Task.Result`, `.Wait()`, or `GetAwaiter().GetResult()` on hot paths
- [ ] Thread safety preserved (checked existing lock/concurrent patterns)
- [ ] Optimization comments explain non-obvious choices
- [ ] Code still compiles and passes all existing checks

View file

@ -0,0 +1,386 @@
---
name: general-prompt-engineer
description: create, repair, compress, and optimize prompts, system messages, tool instructions, schemas, and eval rubrics for general tasks across writing, research, coding, analysis, planning, tutoring, automation, and agent workflows. use when the user wants a new prompt, wants an existing prompt improved, wants prompt failures debugged, or needs better structure for grounding, tool use, output format, or reliability.
---
# Prompt Engineer
Build prompts that are clear, compact, reliable, and easy to evaluate. Optimize for modern frontier models, but keep prompts portable across model families unless the user explicitly asks for model-specific tuning.
## Default workflow
1. Diagnose the request.
2. Decide whether prompt changes are the real fix.
3. Gather only missing information.
4. Choose the lightest structure that will work.
5. Draft the prompt.
6. Stress-test it mentally.
7. Deliver only what the user asked for.
### 1) Diagnose the request
Extract:
- objective
- target actor or model
- required output
- constraints and non-goals
- source material and freshness needs
- tool or schema needs
- likely failure modes
- interaction mode: interactive, one-shot, or automated
Before rewriting, check whether the problem is actually caused by:
- the wrong model
- weak or excessive tool design
- missing retrieval or grounding
- missing schema or validation
- missing evals
- an overcomplicated workflow
If prompt changes are not the main lever, say so and adjust the solution.
### 2) Gather only missing information
Ask targeted questions only when the answer would materially change the prompt or output.
Usually clarify:
- output shape
- hard constraints
- source of truth
- allowed tools
- audience or tone, if important
- success criteria or examples, if available
Do not run a long interview. If the user likely wants speed, state a small set of assumptions and proceed.
### 3) Choose the lightest structure that will work
Use this ladder:
- Plain prompt: simple tasks with clear outputs
- Labeled sections: tasks with multiple constraints or source material
- Schema-based prompt: machine-validated output or tool calls
- Staged workflow: multi-step transformations, verification, or research synthesis
- Agent prompt: only when autonomy, tools, or long-horizon execution are required
- Multi-agent design: only if evals or clear role separation justify it
Do not force a giant template onto a small task.
## Core rules
### Instruction clarity
- Put the main task and required output near the top.
- Use direct verbs.
- Say what to do, not only what to avoid.
- Make constraints measurable when possible.
- Name out-of-bounds behavior explicitly.
- Do not make the model infer facts or parameters you already know.
- Remove contradictions before adding more guidance.
### Context loading discipline
- Include only context that helps the task.
- Separate stable instructions from variable task data.
- Label documents, examples, and reference material clearly.
- For source-heavy prompts, keep the operative question easy to find.
- For long-document work, anchor important claims to quoted text, citations, or section references when precision matters.
- For very long or noisy documents, consider an evidence-first step: extract the relevant passages first, then synthesize.
- Remove repeated policies, repeated facts, and ornamental prose.
- When a task is dominated by long source material, use strong delimiters and make the final requested action unmistakable.
### Reasoning control
- Do not force visible chain-of-thought by default.
- For reasoning-first models, prefer concise high-level guidance such as "reason carefully", "check assumptions", or "verify before answering" rather than "think step by step".
- Ask for visible reasoning only when it serves the task: tutoring, auditability, debugging, derivations, safety review, or explicit rationale requests.
- If one prompt is trying to do too much, split it into stages instead of demanding a long visible reasoning trace.
- If the target model supports extended or internal thinking, rely on that before adding verbose reasoning rituals.
### Examples
- Try zero-shot first for strong modern models.
- Add examples only when they reduce ambiguity, enforce style, or demonstrate hard edge cases.
- Keep examples high-quality, diverse, and tightly aligned with the instructions.
- Do not include many examples that teach accidental patterns or waste context.
### Structure and output design
- Use plain markdown or labeled sections for most prompts.
- Use XML tags or equivalent delimiters when instructions, context, examples, and documents might otherwise get mixed together.
- Use schemas when the output must be machine-checked.
- For external actions, use tool or function calling; for user-facing structured data, use structured response formats.
- Design schemas so valid failure states, uncertainty, abstention, or partial completion can be represented when needed.
- Do not over-constrain fields beyond what downstream systems actually require.
- Include fallback behavior for incompatible input, missing fields, uncertainty, or refusal states.
- Treat format validation and content validation as separate problems.
### Tool-use guidance
- Add tools only when the task truly needs external information, computation, or actions.
- Keep the tool set small, distinct, and easy to choose between.
- State when each tool should be used and when it should not be used.
- Prefer tools that return high-signal results over bulky raw dumps.
- Combine tightly coupled actions when that reduces tool-selection ambiguity.
- For complex tools, clear descriptions and valid examples matter more than more tools.
### Grounding and hallucination reduction
- Give the model permission to say "I don't know" or "not enough information".
- Name the allowed sources of truth.
- For document-grounded tasks, require evidence before synthesis when precision matters.
- For fresh, unstable, or high-stakes facts, require browsing or verification.
- Ask the model to separate facts, inferences, and recommendations when confusion is likely.
- In high-stakes domains, unsupported claims should be withheld, not guessed.
### Ambiguity handling
- If ambiguity is blocking and the setting is interactive, ask concise high-leverage questions.
- If ambiguity is non-blocking or interaction is costly, state the best assumption and proceed.
- Avoid clarifying questions that do not materially change the answer.
- In one-shot or automated settings, prefer explicit assumptions over stalled execution.
### Verbosity control
- Set a default brevity level when length matters.
- Constrain section count, sentence count, or bullet count when needed.
- Ask for direct answers first, then supporting detail if useful.
- Do not require long preambles, summaries, or checklists unless they clearly help.
### Modularity and portability
- Keep prompt blocks reusable: role, objective, context, tools, output, quality bar.
- Separate required behavior from optional preferences.
- Avoid vendor-specific magic phrases unless the user wants model-specific tuning.
- If the prompt is model-specific, label which parts are portable and which parts are tuned.
## Model-family adjustments
Use this section only when the target model family is known.
### GPT-5.x and similar reasoning-first models
- Keep prompts simple and direct.
- Prefer high-level reasoning guidance over narrated reasoning instructions.
- Use delimiters for clarity.
- Start zero-shot, then add examples only if needed.
- Be explicit about output shape, scope, and verbosity.
### Claude 4.x, Opus-style models, and extended-thinking modes
- XML-style structure can work especially well for separating instructions, context, examples, and documents.
- Prompt chaining can outperform one giant prompt on multi-step transformations.
- Well-chosen examples can help with format fidelity and edge cases.
- If extended thinking is available, start with broad reasoning instructions before prescribing a detailed step list.
- For long-context analysis, labeled documents and evidence grounding are especially important.
### API and production settings
- Prefer native schema enforcement, tool calling, prompt versioning, and evals over prompt-only fixes.
- Pin model versions when behavior stability matters.
- Re-run evals after each meaningful prompt change.
## Prompt construction pattern
Use only the blocks that earn their token cost.
Minimal pattern:
```text
Task:
Constraints:
Output:
```
Structured pattern:
```xml
<role>...</role>
<objective>...</objective>
<context>...</context>
<constraints>...</constraints>
<tools>...</tools>
<output_format>...</output_format>
<quality_bar>...</quality_bar>
```
Optional blocks:
- `<examples>`
- `<source_material>`
- `<evaluation_criteria>`
- `<fallback_behavior>`
Use a role only when it meaningfully sharpens expertise, tone, or decision criteria. Avoid generic filler roles.
## Rewrite policy for existing prompts
When the user provides a prompt to improve:
1. Preserve what already works.
2. Identify contradictions, redundancy, vagueness, missing constraints, and wasted tokens.
3. Make surgical edits first.
4. Rewrite from scratch only if the prompt architecture is fundamentally wrong.
5. Match the user's requested output:
- edited version only
- clean rebuild only
- both, if useful and requested
## Special-case guidance
### System and developer prompts
- Keep stable behavior here and move per-request data to the task or user layer.
- Put precedence, tool boundaries, non-goals, and refusal or escalation rules in the highest-priority layer.
- Do not bury critical rules inside long policy prose.
### Research prompts
Specify:
- freshness requirements
- preferred source types
- citation behavior
- contradiction handling
- whether to ask questions or cover likely interpretations
- how facts, inferences, and recommendations should be separated
### Writing prompts
Specify:
- audience
- intent
- tone
- length
- must-include points
- style examples only if style fidelity matters
### Coding prompts
Specify:
- environment and versions
- boundaries and non-goals
- files, interfaces, or contracts that matter
- acceptance tests
- minimal-change versus refactor expectations
### Summarization and extraction prompts
Specify:
- whether faithfulness, compression, or completeness is the priority
- the exact output schema
- how evidence should be anchored for sensitive claims
### Translation and transformation prompts
Specify:
- source language and target language, if known
- fidelity versus naturalness
- terminology that must stay fixed
- formatting or markup preservation rules
### Tutoring prompts
Specify:
- learner level
- whether to give the answer immediately or guide toward it
- explanation depth
- how to check understanding
- whether to show full derivations, hints, or worked examples
### Agent and workflow prompts
Specify:
- objective and success condition
- allowed tools and forbidden actions
- when to plan versus when to act
- stop conditions and max retries
- checkpoint, handoff, or log format
- memory rules: what to preserve versus discard
- fallback or escalation path
Use multi-agent designs only when roles are truly distinct and the extra coordination cost is justified.
### Safety-sensitive prompts
Require:
- supported claims
- explicit uncertainty
- refusal or escalation behavior where appropriate
- no guessing under pressure
## Stress-test before delivering
Mentally test the prompt against:
- a normal case
- a minimal-input case
- an edge case
- an ambiguous case
- a formatting case
- a hallucination-prone case
For agent or workflow prompts, also test:
- wrong-tool temptation
- stale-data temptation
- scope creep
- over-verbosity
- fallback behavior
If the prompt fails any test, tighten or simplify it.
## Evaluation method
When the user wants reliability, add or suggest a lightweight eval plan:
1. Define success criteria.
2. Build a test set from real cases plus edge and adversarial cases.
3. Prefer automated grading when possible.
4. Calibrate automated or model-based judges against a smaller human-reviewed set when stakes are meaningful.
5. Use pairwise comparison, classification, pass-fail, or rubric-based scoring instead of only open-ended judgment.
6. Track regressions after each prompt change.
7. Start simple. Add workflows or multi-agent designs only if evals justify them.
Good eval sets usually include:
- common real tasks
- boundary cases
- malformed inputs
- conflicting instructions
- long-context cases
- tool-misuse temptations
- safety-sensitive cases
- multilingual or format-variant inputs, if relevant
## Deliverables
Return only what the user asked for. By default:
1. the final prompt
2. brief usage notes
3. stated assumptions, if any
4. optional variants only when clearly useful:
- minimal
- robust
- model-specific
- api message split
If the user asks for one prompt only, do not add extra frameworks or commentary.
## Anti-patterns
- forcing chain-of-thought everywhere
- confusing verbosity with quality
- piling on redundant rules
- using brittle giant templates for small tasks
- requiring tools without a real need
- exposing unnecessary internal process in user-facing outputs
- adding examples that conflict with the instructions
- asking many clarifying questions when a sane assumption would do
- treating a model, retrieval, or tool problem as only a prompt problem
- building multi-agent systems before a simpler design has been evaluated
- vague quality bars like "be excellent" without measurable criteria
## Final quality bar
A prompt is ready when it is:
- clear about the task
- explicit about success criteria
- free of contradictions
- no more verbose than necessary
- grounded in the right sources
- structured enough for the task, but not heavier than needed
- resilient to likely ambiguity
- matched to the target model and interaction mode
- easy to maintain, test, and adapt

142
.skills/humanizer/README.md Normal file
View file

@ -0,0 +1,142 @@
# Humanizer
A Claude Code skill that removes signs of AI-generated writing from text, making it sound more natural and human.
## Installation
### Recommended (clone directly into Claude Code skills directory)
```bash
mkdir -p ~/.claude/skills
git clone https://github.com/blader/humanizer.git ~/.claude/skills/humanizer
```
### Manual install/update (only the skill file)
If you already have this repo cloned (or you downloaded `SKILL.md`), copy the skill file into Claude Codes skills directory:
```bash
mkdir -p ~/.claude/skills/humanizer
cp SKILL.md ~/.claude/skills/humanizer/
```
## Usage
In Claude Code, invoke the skill:
```
/humanizer
[paste your text here]
```
Or ask Claude to humanize text directly:
```
Please humanize this text: [your text]
```
## Overview
Based on [Wikipedia's "Signs of AI writing"](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing) guide, maintained by WikiProject AI Cleanup. This comprehensive guide comes from observations of thousands of instances of AI-generated text.
### Key Insight from Wikipedia
> "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."
## 24 Patterns Detected (with Before/After Examples)
### Content Patterns
| # | Pattern | Before | After |
|---|---------|--------|-------|
| 1 | **Significance inflation** | "marking a pivotal moment in the evolution of..." | "was established in 1989 to collect regional statistics" |
| 2 | **Notability name-dropping** | "cited in NYT, BBC, FT, and The Hindu" | "In a 2024 NYT interview, she argued..." |
| 3 | **Superficial -ing analyses** | "symbolizing... reflecting... showcasing..." | Remove or expand with actual sources |
| 4 | **Promotional language** | "nestled within the breathtaking region" | "is a town in the Gonder region" |
| 5 | **Vague attributions** | "Experts believe it plays a crucial role" | "according to a 2019 survey by..." |
| 6 | **Formulaic challenges** | "Despite challenges... continues to thrive" | Specific facts about actual challenges |
### Language Patterns
| # | Pattern | Before | After |
|---|---------|--------|-------|
| 7 | **AI vocabulary** | "Additionally... testament... landscape... showcasing" | "also... remain common" |
| 8 | **Copula avoidance** | "serves as... features... boasts" | "is... has" |
| 9 | **Negative parallelisms** | "It's not just X, it's Y" | State the point directly |
| 10 | **Rule of three** | "innovation, inspiration, and insights" | Use natural number of items |
| 11 | **Synonym cycling** | "protagonist... main character... central figure... hero" | "protagonist" (repeat when clearest) |
| 12 | **False ranges** | "from the Big Bang to dark matter" | List topics directly |
### Style Patterns
| # | Pattern | Before | After |
|---|---------|--------|-------|
| 13 | **Em dash overuse** | "institutions—not the people—yet this continues—" | Use commas or periods |
| 14 | **Boldface overuse** | "**OKRs**, **KPIs**, **BMC**" | "OKRs, KPIs, BMC" |
| 15 | **Inline-header lists** | "**Performance:** Performance improved" | Convert to prose |
| 16 | **Title Case Headings** | "Strategic Negotiations And Partnerships" | "Strategic negotiations and partnerships" |
| 17 | **Emojis** | "🚀 Launch Phase: 💡 Key Insight:" | Remove emojis |
| 18 | **Curly quotes** | `said “the project”` | `said "the project"` |
### Communication Patterns
| # | Pattern | Before | After |
|---|---------|--------|-------|
| 19 | **Chatbot artifacts** | "I hope this helps! Let me know if..." | Remove entirely |
| 20 | **Cutoff disclaimers** | "While details are limited in available sources..." | Find sources or remove |
| 21 | **Sycophantic tone** | "Great question! You're absolutely right!" | Respond directly |
### Filler and Hedging
| # | Pattern | Before | After |
|---|---------|--------|-------|
| 22 | **Filler phrases** | "In order to", "Due to the fact that" | "To", "Because" |
| 23 | **Excessive hedging** | "could potentially possibly" | "may" |
| 24 | **Generic conclusions** | "The future looks bright" | Specific plans or facts |
## Full Example
**Before (AI-sounding):**
> Great question! Here is an essay on this topic. I hope this helps!
>
> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
>
> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation.
>
> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The technology has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment.
>
> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers.
> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards.
> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends.
>
> While specific details are limited based on available information, it could potentially be argued that these tools might have some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices.
>
> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if youd like me to expand on any section!
**After (Humanized):**
> AI coding assistants speed up some tasks. In a 2024 study by Google, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
>
> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention.
>
> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library.
>
> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants.
>
> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right.
## References
- [Wikipedia: Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing) - Primary source
- [WikiProject AI Cleanup](https://en.wikipedia.org/wiki/Wikipedia:WikiProject_AI_Cleanup) - Maintaining organization
## Version History
- **2.1.1** - Fixed pattern #18 example (curly quotes vs straight quotes)
- **2.1.0** - Added before/after examples for all 24 patterns
- **2.0.0** - Complete rewrite based on raw Wikipedia article content
- **1.0.0** - Initial release
## License
MIT

467
.skills/humanizer/SKILL.md Normal file
View file

@ -0,0 +1,467 @@
---
name: humanizer
description: |
Remove signs of AI-generated writing from text. Use when editing or reviewing
text to make it sound more natural and human-written. Based on Wikipedia's
comprehensive "Signs of AI writing" guide. Detects and fixes patterns including:
inflated symbolism, promotional language, superficial -ing analyses, vague
attributions, em dash overuse, rule of three, AI vocabulary words, negative
parallelisms, and excessive conjunctive phrases. Use this skill when writing documentation for MCC.
allowed-tools:
- Read
- Write
- Edit
- Grep
- Glob
- AskUserQuestion
---
# Humanizer: Remove AI Writing Patterns
You are a writing editor that identifies and removes signs of AI-generated text to make writing sound more natural and human. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup.
## Your Task
When given text to humanize:
1. **Identify AI patterns** - Scan for the patterns listed below
2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives
3. **Preserve meaning** - Keep the core message intact
4. **Maintain voice** - Match the intended tone (formal, casual, technical, etc.)
5. **Add soul** - Don't just remove bad patterns; inject actual personality
---
## PERSONALITY AND SOUL
Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it.
### Signs of soulless writing (even if technically "clean"):
- Every sentence is the same length and structure
- No opinions, just neutral reporting
- No acknowledgment of uncertainty or mixed feelings
- No first-person perspective when appropriate
- No humor, no edge, no personality
- Reads like a Wikipedia article or press release
### How to add voice:
**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons.
**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up.
**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive."
**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking.
**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human.
**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching."
### Before (clean but soulless):
> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear.
### After (has a pulse):
> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night.
---
## CONTENT PATTERNS
### 1. Undue Emphasis on Significance, Legacy, and Broader Trends
**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted
**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic.
**Before:**
> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance.
**After:**
> The Statistical Institute of Catalonia was established in 1989 to collect and publish regional statistics independently from Spain's national statistics office.
---
### 2. Undue Emphasis on Notability and Media Coverage
**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence
**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context.
**Before:**
> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers.
**After:**
> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods.
---
### 3. Superficial Analyses with -ing Endings
**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing...
**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth.
**Before:**
> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land.
**After:**
> The temple uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast.
---
### 4. Promotional and Advertisement-like Language
**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning
**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics.
**Before:**
> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty.
**After:**
> Alamata Raya Kobo is a town in the Gonder region of Ethiopia, known for its weekly market and 18th-century church.
---
### 5. Vague Attributions and Weasel Words
**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited)
**Problem:** AI chatbots attribute opinions to vague authorities without specific sources.
**Before:**
> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem.
**After:**
> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences.
---
### 6. Outline-like "Challenges and Future Prospects" Sections
**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook
**Problem:** Many LLM-generated articles include formulaic "Challenges" sections.
**Before:**
> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth.
**After:**
> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods.
---
## LANGUAGE AND GRAMMAR PATTERNS
### 7. Overused "AI Vocabulary" Words
**High-frequency AI words:** Additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant
**Problem:** These words appear far more frequently in post-2023 text. They often co-occur.
**Before:**
> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet.
**After:**
> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south.
---
### 8. Avoidance of "is"/"are" (Copula Avoidance)
**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a]
**Problem:** LLMs substitute elaborate constructions for simple copulas.
**Before:**
> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet.
**After:**
> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet.
---
### 9. Negative Parallelisms
**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused.
**Before:**
> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement.
**After:**
> The heavy beat adds to the aggressive tone.
---
### 10. Rule of Three Overuse
**Problem:** LLMs force ideas into groups of three to appear comprehensive.
**Before:**
> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights.
**After:**
> The event includes talks and panels. There's also time for informal networking between sessions.
---
### 11. Elegant Variation (Synonym Cycling)
**Problem:** AI has repetition-penalty code causing excessive synonym substitution.
**Before:**
> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home.
**After:**
> The protagonist faces many challenges but eventually triumphs and returns home.
---
### 12. False Ranges
**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale.
**Before:**
> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter.
**After:**
> The book covers the Big Bang, star formation, and current theories about dark matter.
---
## STYLE PATTERNS
### 13. Em Dash Overuse
**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing.
**Before:**
> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents.
**After:**
> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" as an address, yet this mislabeling continues in official documents.
---
### 14. Overuse of Boldface
**Problem:** AI chatbots emphasize phrases in boldface mechanically.
**Before:**
> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**.
**After:**
> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard.
---
### 15. Inline-Header Vertical Lists
**Problem:** AI outputs lists where items start with bolded headers followed by colons.
**Before:**
> - **User Experience:** The user experience has been significantly improved with a new interface.
> - **Performance:** Performance has been enhanced through optimized algorithms.
> - **Security:** Security has been strengthened with end-to-end encryption.
**After:**
> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption.
---
### 16. Title Case in Headings
**Problem:** AI chatbots capitalize all main words in headings.
**Before:**
> ## Strategic Negotiations And Global Partnerships
**After:**
> ## Strategic negotiations and global partnerships
---
### 17. Emojis
**Problem:** AI chatbots often decorate headings or bullet points with emojis.
**Before:**
> 🚀 **Launch Phase:** The product launches in Q3
> 💡 **Key Insight:** Users prefer simplicity
> ✅ **Next Steps:** Schedule follow-up meeting
**After:**
> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting.
---
### 18. Curly Quotation Marks
**Problem:** ChatGPT uses curly quotes (“...”) instead of straight quotes ("...").
**Before:**
> He said “the project is on track” but others disagreed.
**After:**
> He said "the project is on track" but others disagreed.
---
## COMMUNICATION PATTERNS
### 19. Collaborative Communication Artifacts
**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a...
**Problem:** Text meant as chatbot correspondence gets pasted as content.
**Before:**
> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section.
**After:**
> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest.
---
### 20. Knowledge-Cutoff Disclaimers
**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information...
**Problem:** AI disclaimers about incomplete information get left in text.
**Before:**
> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s.
**After:**
> The company was founded in 1994, according to its registration documents.
---
### 21. Sycophantic/Servile Tone
**Problem:** Overly positive, people-pleasing language.
**Before:**
> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors.
**After:**
> The economic factors you mentioned are relevant here.
---
## FILLER AND HEDGING
### 22. Filler Phrases
**Before → After:**
- "In order to achieve this goal" → "To achieve this"
- "Due to the fact that it was raining" → "Because it was raining"
- "At this point in time" → "Now"
- "In the event that you need help" → "If you need help"
- "The system has the ability to process" → "The system can process"
- "It is important to note that the data shows" → "The data shows"
---
### 23. Excessive Hedging
**Problem:** Over-qualifying statements.
**Before:**
> It could potentially possibly be argued that the policy might have some effect on outcomes.
**After:**
> The policy may affect outcomes.
---
### 24. Generic Positive Conclusions
**Problem:** Vague upbeat endings.
**Before:**
> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction.
**After:**
> The company plans to open two more locations next year.
---
## Process
1. Read the input text carefully
2. Identify all instances of the patterns above
3. Rewrite each problematic section
4. Ensure the revised text:
- Sounds natural when read aloud
- Varies sentence structure naturally
- Uses specific details over vague claims
- Maintains appropriate tone for context
- Uses simple constructions (is/are/has) where appropriate
5. Present the humanized version
## Output Format
Provide:
1. The rewritten text
2. A brief summary of changes made (optional, if helpful)
---
## Full Example
**Before (AI-sounding):**
> Great question! Here is an essay on this topic. I hope this helps!
>
> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
>
> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation.
>
> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The technology has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment.
>
> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers.
> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards.
> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends.
>
> While specific details are limited based on available information, it could potentially be argued that these tools might have some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices.
>
> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if youd like me to expand on any section!
**After (Humanized):**
> AI coding assistants speed up some tasks. In a 2024 study by Google, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
>
> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention.
>
> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library.
>
> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants.
>
> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right.
**Changes made:**
- Removed chatbot artifacts ("Great question!", "I hope this helps!", "Let me know if...")
- Removed significance inflation ("testament", "pivotal moment", "evolving landscape", "vital role")
- Removed promotional language ("groundbreaking", "nestled", "seamless, intuitive, and powerful")
- Removed vague attributions ("Industry observers") and replaced with specific sources (Google study, named engineers, Uplevel study)
- Removed superficial -ing phrases ("underscoring", "highlighting", "reflecting", "contributing to")
- Removed negative parallelism ("It's not just X; it's Y")
- Removed rule-of-three patterns and synonym cycling ("catalyst/partner/foundation")
- Removed false ranges ("from X to Y, from A to B")
- Removed em dashes, emojis, boldface headers, and curly quotes
- Removed copula avoidance ("serves as", "functions as", "stands as") in favor of "is"/"are"
- Removed formulaic challenges section ("Despite challenges... continues to thrive")
- Removed knowledge-cutoff hedging ("While specific details are limited...")
- Removed excessive hedging ("could potentially be argued that... might have some")
- Removed filler phrases ("In order to", "At its core")
- Removed generic positive conclusion ("the future looks bright", "exciting times lie ahead")
- Replaced media name-dropping with specific claims from specific sources
- Used simple sentence structures and concrete examples
---
## Reference
This skill is based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia.
Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."

View file

@ -0,0 +1,107 @@
---
name: mcc-chatbot-authoring
description: Create, modify, repair, and wire Minecraft Console Client ChatBots and standalone `/script` bots. Use this whenever the user wants an MCC bot, C# script bot, chat or event handlers, periodic automation, movement logic, inventory logic, plugin-channel handling, or asks to fix or port an existing bot; default to standalone `//MCCScript` bots unless the user explicitly asks for a built-in MCC bot or repo wiring.
---
# MCC ChatBot Authoring
Implement MCC chat bots against the bundled MCC authoring reference. Do not invent methods, lifecycle hooks, or registration steps.
Always read:
- `references/authoring-reference.md`
Load only as needed:
- `references/pattern-cookbook.md` for concrete standalone examples
- `assets/script-chatbot-template.cs` for the default standalone `/script` path
- `assets/builtin-chatbot-template.cs` only when the user explicitly requests a built-in bot
If the current workspace contains an MCC checkout, verify final names and signatures against local sources before editing. The skill should still work without those files.
If there is no MCC checkout available, rely on the bundled reference and cookbook as the full source of truth for authoring patterns.
## Choose the bot type first
1. Default to a standalone script bot loaded with `/script`.
2. Only choose a built-in bot when the user explicitly asks for a compiled MCC bot, repo wiring, automatic config loading, or changes under the built-in bot system.
3. If the prompt is ambiguous, infer the likely target from commands, requested output files, or phrasing, state the assumption briefly, and proceed.
4. If a user says only "make a bot", do not create a built-in bot.
## Source priority
When the local MCC checkout is available, prefer these sources in this order:
1. `MinecraftClient/Scripting/ChatBot.cs` and current files under `MinecraftClient/ChatBots/`
2. the bundled `references/authoring-reference.md`
3. the bundled `references/pattern-cookbook.md`
4. older `MinecraftClient/config/` sample bots only for ideas, not as the default scaffold
If an older sample conflicts with the current built-in bots, follow the current built-in bots.
If the local checkout is not available, do not block on missing repo files. Use the bundled references directly.
## Hard rules
- Only use lifecycle hooks and helpers documented in the bundled reference or verified in the target codebase.
- Do not send chat from `Initialize()`. Use `AfterGameJoined()` once the session can send messages.
- Prefer the current Brigadier command-registration pattern for built-in bots. Do not introduce `ChatBotCommand` unless the surrounding code already uses it.
- For message parsing, normalize with `GetVerbatim(text)` before `IsChatMessage(...)` or `IsPrivateMessage(...)`.
- Clean up everything you register or start: commands, plugin channels, threads, timers, and movement locks.
- If a built-in bot or long-running automation controls movement, follow a movement-lock pattern and release it on every stop path. Do not add `BotMovementLock` to a simple standalone `/script` bot unless the prompt or surrounding code explicitly needs shared movement coordination.
- For built-in bots, follow the host codebase's localization and config-comment conventions instead of scattering hardcoded user-facing text.
- For new code, prefer `Initialize()` over constructors for prerequisite checks and unload decisions.
- In this repo, built-in bot wiring usually means edits in `MinecraftClient/Settings.cs` and `MinecraftClient/McClient.cs` in addition to the bot class.
- For repair tasks, preserve the existing bot type and file layout unless the user explicitly asks for a conversion or restructure.
## Standalone script bots
Use the exact MCC metadata format from the bundled reference.
This is the default path for new work.
The script should usually:
- keep `Initialize()` for cheap setup only
- use `GetText(...)`, `AfterGameJoined()`, and other event hooks for live behavior
- log with `LogToConsole(...)`
- send server chat or commands with `SendText(...)`
- use `PerformInternalCommand(...)` only for MCC internal commands
- add `//using MinecraftClient.Inventory` in metadata when the script uses inventory types explicitly
- reuse the standalone snippets in `references/pattern-cookbook.md` before inventing new scaffolding
- keep load instructions explicit, usually `/script FileName.cs`
## Built-in bots
Built-in bots usually need three pieces:
- the bot class itself
- config wiring in the chat-bot config model
- bot registration in the load flow
If the codebase exposes commands, follow the built-in command and unload pattern from the bundled reference. If it exposes new settings or status text, follow the codebase's localization and config-comment patterns.
When working in this checkout, built-in bot delivery usually needs:
- a new file under `MinecraftClient/ChatBots/`
- a config property inside `Settings.ChatBotConfigHealper.ChatBotConfig`
- a `BotLoad(new YourBot())` line inside `McClient.RegisterBots(...)`
- literal code snippets or patch hunks for the `Settings.cs` property and the `McClient.cs` registration line, not only prose notes
## Repair flow
When the user asks to fix or debug a bot:
- identify whether it is standalone or built-in and keep that shape unless told otherwise
- remove the broken pattern first, then preserve the intended behavior
- check especially for these regressions: `SendText(...)` in `Initialize()`, raw formatted chat parsing, inventory snapshot mutation, missing command unregister, missing plugin-channel unregister, and unreleased movement locks
- reuse the local repo's modern pattern instead of patching around a legacy helper when the helper is no longer current
## Delivery checklist
Before finishing, verify:
- the class inherits `ChatBot`
- the chosen overrides exist in the MCC ChatBot API
- standalone script metadata is exact if this is a `/script` bot
- built-in bots are fully wired into config and registration if needed
- all command registrations, background work, and movement locks are released
- files and namespaces match the surrounding codebase
## Output
When you implement or modify a bot:
- state whether it is a standalone script bot or built-in bot
- list the files you changed
- mention any required config keys or the MCC command used to load it
- when built-in wiring is involved, show the exact inserted code lines or patch hunks for `Settings.cs` and `McClient.cs`
- call out assumptions briefly if the user did not specify bot type or trigger behavior

View file

@ -0,0 +1,57 @@
// Use this template only when the user explicitly requests a built-in MCC bot.
using MinecraftClient.Scripting;
using Tomlet.Attributes;
namespace MinecraftClient.ChatBots
{
public class ExampleBot : ChatBot
{
private const string BotName = "ExampleBot";
public static Configs Config = new();
[TomlDoNotInlineObject]
public class Configs
{
public bool Enabled = false;
public void OnSettingUpdate()
{
}
}
public override void Initialize()
{
LogToConsole(BotName, "Initialized.");
}
public override void AfterGameJoined()
{
}
public override void GetText(string text)
{
text = GetVerbatim(text);
string message = "";
string username = "";
if (IsPrivateMessage(text, ref message, ref username))
{
}
else if (IsChatMessage(text, ref message, ref username))
{
}
}
public override void OnUnload()
{
}
public override bool OnDisconnect(DisconnectReason reason, string message)
{
return false;
}
}
}

View file

@ -0,0 +1,37 @@
//MCCScript 1.0
MCC.LoadBot(new ExampleScriptBot());
//MCCScript Extensions
public class ExampleScriptBot : ChatBot
{
public override void Initialize()
{
LogToConsole("ExampleScriptBot initialized.");
}
public override void AfterGameJoined()
{
// Safe place for startup chat or commands.
}
public override void GetText(string text)
{
text = GetVerbatim(text);
string message = "";
string username = "";
if (IsPrivateMessage(text, ref message, ref username))
{
LogToConsole("PM from " + username + ": " + message);
return;
}
if (IsChatMessage(text, ref message, ref username))
{
LogToConsole("Chat from " + username + ": " + message);
}
}
}

View file

@ -0,0 +1,492 @@
# MCC ChatBot Reference
Self-contained authoring notes for Minecraft Console Client chat bots.
## Bot types
MCC supports two common authoring paths:
- standalone script bots loaded at runtime with `/script`
- built-in bots compiled into the MCC codebase
Default to a standalone `/script` bot unless the user explicitly asks for a built-in bot or repo wiring.
## Embedded current patterns
This skill is intended to work even without an MCC checkout. The patterns below capture the important behavior that would otherwise be borrowed from current repo examples.
If the local repo is available, you can verify against files such as `TestBot.cs`, `RemoteControl.cs`, `FollowPlayer.cs`, `ItemsCollector.cs`, and `Farmer.cs`. If it is not available, use the embedded patterns here directly.
### Minimal chat parsing pattern
Use this as the baseline for public/private chat handling:
```csharp
public override void GetText(string text)
{
string message = "";
string sender = "";
text = GetVerbatim(text);
if (IsPrivateMessage(text, ref message, ref sender))
{
LogToConsole("PM from " + sender + ": " + message);
}
else if (IsChatMessage(text, ref message, ref sender))
{
LogToConsole("Chat from " + sender + ": " + message);
}
}
```
What matters:
- normalize first with `GetVerbatim(text)`
- handle PMs before public chat if both matter
- keep simple chat bots deterministic and small
### Owner-gated PM control pattern
Use this when a bot owner should be able to whisper MCC internal commands:
```csharp
public override void GetText(string text)
{
text = GetVerbatim(text).Trim();
string command = "";
string sender = "";
if (IsPrivateMessage(text, ref command, ref sender)
&& Settings.Config.Main.Advanced.BotOwners.Contains(sender.ToLowerInvariant()))
{
CmdResult result = new();
PerformInternalCommand(command, ref result);
SendPrivateMessage(sender, result.ToString());
}
}
```
What matters:
- `PerformInternalCommand(...)` is for MCC commands, not server chat commands
- owner gating should use `Settings.Config.Main.Advanced.BotOwners`
- if `CmdResult` is used in a standalone script, add `//using MinecraftClient.CommandHandler`
### Periodic work pattern
Use `Update()` plus a counter or timestamp for simple repeated work:
```csharp
private int count = 0;
public override void Update()
{
count++;
if (count < Settings.DoubleToTick(60))
return;
count = 0;
SendText("/list");
}
```
What matters:
- avoid a worker thread for simple periodic loops
- avoid `Thread.Sleep(...)` inside `Update()`
- if sending chat, do it from a join-safe path like `Update()` or `AfterGameJoined()`, not `Initialize()`
### Built-in Brigadier command pattern
Use this for built-in command bots:
```csharp
public override void Initialize()
{
McClient.dispatcher.Register(l => l.Literal("help")
.Then(l => l.Literal(CommandName)
.Executes(r => OnCommandHelp(r.Source, string.Empty))
)
);
McClient.dispatcher.Register(l => l.Literal(CommandName)
.Then(l => l.Literal("stop")
.Executes(r => OnCommandStop(r.Source)))
.Then(l => l.Literal("_help")
.Executes(r => OnCommandHelp(r.Source, string.Empty))
.Redirect(McClient.dispatcher.GetRoot().GetChild("help").GetChild(CommandName)))
);
}
public override void OnUnload()
{
McClient.dispatcher.Unregister(CommandName);
McClient.dispatcher.GetRoot().GetChild("help").RemoveChild(CommandName);
}
```
What matters:
- register commands in `Initialize()`
- unregister the command tree in `OnUnload()`
- remove the help child you added in `OnUnload()`
- prefer this over legacy command wrappers for new built-in work
### Built-in config and wiring pattern
Use this as the default built-in shape:
```csharp
public class ExampleBot : ChatBot
{
public static Configs Config = new();
[TomlDoNotInlineObject]
public class Configs
{
public bool Enabled = false;
public void OnSettingUpdate()
{
}
}
}
```
Typical host wiring shape:
```csharp
[TomlPrecedingComment("$ChatBot.ExampleBot$")]
public ChatBots.ExampleBot.Configs ExampleBot
{
get { return ChatBots.ExampleBot.Config; }
set { ChatBots.ExampleBot.Config = value; ChatBots.ExampleBot.Config.OnSettingUpdate(); }
}
```
```csharp
if (Config.ChatBot.ExampleBot.Enabled) { BotLoad(new ExampleBot()); }
```
What matters:
- built-in configurable bots default to `Enabled = false`
- `OnSettingUpdate()` is the place to normalize config values
- built-in delivery is incomplete without both config wiring and load registration
### Movement gating pattern
Use this shape when a built-in bot owns movement:
```csharp
public override void Initialize()
{
if (!GetEntityHandlingEnabled())
{
LogToConsole("Entity handling is required.");
UnloadBot();
return;
}
if (!GetTerrainEnabled())
{
LogToConsole("Terrain handling is required.");
UnloadBot();
return;
}
}
```
```csharp
var movementLock = BotMovementLock.Instance;
if (movementLock is { IsLocked: true })
return;
movementLock?.Lock("Example Bot");
```
```csharp
public override void OnUnload()
{
BotMovementLock.Instance?.UnLock("Example Bot");
}
```
What matters:
- guard terrain and entity handling before movement logic
- built-in movement bots should use `BotMovementLock`
- release the lock on every stop path, including unload and disconnect-sensitive flows
### Dropped-item collector pattern
Use this as the standalone item-search baseline:
```csharp
private DateTime nextScan = DateTime.MinValue;
public override void Update()
{
var now = DateTime.UtcNow;
if (now < nextScan || ClientIsMoving())
return;
nextScan = now.AddSeconds(1);
var here = GetCurrentLocation();
var target = GetEntities().Values
.Where(entity => entity.Type == EntityType.Item && entity.Location.Distance(here) <= 15)
.OrderBy(entity => entity.Location.Distance(here))
.FirstOrDefault();
if (target != null)
MoveToLocation(target.Location);
}
```
What matters:
- simple standalone collectors do not need a worker thread
- simple standalone collectors also do not need `BotMovementLock` by default
- `GetEntities()` plus distance ordering is the core search pattern
### Inventory selection pattern
Use this as the default hotbar-switch pattern:
```csharp
private bool TrySwitchToItem(ItemType itemType)
{
var inventory = GetPlayerInventory();
var hotbarSlots = inventory.SearchItem(itemType)
.Where(slot => slot >= 36 && slot <= 44)
.ToArray();
if (hotbarSlots.Length == 0)
return false;
ChangeSlot((short)(hotbarSlots[0] - 36));
return true;
}
```
What matters:
- guard with `GetInventoryEnabled()`
- search inventory snapshots, but mutate real server state with helpers like `ChangeSlot(...)`
- do not treat local `Container.Items` mutation as real inventory manipulation
Use the older config examples only for ideas, not as primary scaffolding.
## Standalone script format
A standalone script bot has two parts in this order:
1. metadata block
2. one or more C# classes, with the main bot class inheriting `ChatBot`
Required metadata rules:
- line 1 must be exactly `//MCCScript 1.0`
- metadata must include `MCC.LoadBot(new BotClassName());`
- metadata ends with `//MCCScript Extensions`
- optional metadata directives use `//using Namespace` and `//dll SomeLibrary.dll`
- do not insert a space after `//` in metadata directives
Typical runtime flow:
- place the script file beside MCC
- connect to a server
- load it with `/script YourBotFile.cs`
### Namespace linking for inventory code
If a standalone script uses inventory-specific types such as `Container`, `ItemType`, `WindowActionType`, or `ItemMovingHelper`, add this metadata import:
```csharp
//using MinecraftClient.Inventory
```
For built-in bots, use a normal C# import:
```csharp
using MinecraftClient.Inventory;
```
## Lifecycle summary
Common lifecycle hooks:
- `Initialize()`
called once when the bot loads; use it for cheap setup only
- `AfterGameJoined()`
called after the server has been joined successfully, and again after reconnecting; use it when chat can be sent
- `Update()`
called roughly every 100 ms
- `OnUnload()`
called when the bot unloads; release resources here
- `OnDisconnect(DisconnectReason reason, string message)`
called on disconnect; stop background work and clean up reconnect-sensitive state here
Important rule:
- do not send chat from `Initialize()`; use `AfterGameJoined()` instead
- prefer `Initialize()` over constructors for environment checks and resource setup
## Common event hooks
Useful event hooks include:
- `GetText(string text)`
- `GetText(string text, string? json)`
- `OnPlayerJoin(Guid uuid, string name)`
- `OnPlayerLeave(Guid uuid, string? name)`
- `OnEntitySpawn(Entity entity)`
- `OnEntityDespawn(Entity entity)`
- `OnEntityMove(Entity entity)`
- `OnHealthUpdate(float health, int food)`
- `OnMapData(...)`
- `OnInventoryUpdate(int inventoryId)`
- `OnPluginMessage(string channel, byte[] data)`
- `OnNetworkPacket(int packetID, List<byte> packetData, bool isLogin, bool isInbound)`
Only override hooks that actually exist in the target MCC ChatBot API.
## Common helpers
Text and messaging helpers:
- `GetVerbatim(text)` strips Minecraft formatting codes
- `IsChatMessage(text, ref message, ref sender)` parses public chat
- `IsPrivateMessage(text, ref message, ref sender)` parses private chat
- `IsValidName(username)` validates a Minecraft username
- `SendText(text)` sends chat or server commands
- `SendPrivateMessage(player, message)` sends a private message
- `PerformInternalCommand(command, ...)` runs an internal MCC command, not a server command
- `LogToConsole(text)` writes a bot-prefixed console message
Lifecycle and threading helpers:
- `InvokeOnMainThread(...)`
- `ScheduleOnMainThread(...)`
- `ReconnectToTheServer(...)`
- `UnloadBot()`
- `BotLoad(chatBot)`
- `RunScript(filename, ...)`
World and player-state helpers:
- `GetWorld()`
- `GetEntities()`
- `GetCurrentLocation()`
- `ClientIsMoving()`
- `GetOnlinePlayers()`
- `GetOnlinePlayersWithUUID()`
- `GetServerTPS()`
- `GetProtocolVersion()`
Movement and inventory helpers:
- `MoveToLocation(...)`
- `LookAtLocation(...)`
- `GetInventoryEnabled()`
- `GetPlayerInventory()`
- `GetInventories()`
- `GetItemMovingHelper(...)`
- `WindowAction(...)`
- `ChangeSlot(...)`
- `GetCurrentSlot()`
- `UseItemInHand()`
- `UseItemInLeftHand()`
- `CloseInventory(...)`
- `DigBlock(...)`
- `InteractEntity(...)`
## Inventory notes
Inventory handling is optional in MCC. Check `GetInventoryEnabled()` before relying on inventory state or mutation.
Important behavior:
- `GetPlayerInventory()` returns a snapshot copy of the player's inventory
- `GetInventories()` returns current container snapshots
- writing to those `Container` objects locally does not update the server
- to actually change inventory state, use `ChangeSlot(...)`, `WindowAction(...)`, `GetItemMovingHelper(...)`, `UseItemInHand()`, or related helpers
Useful practical facts:
- hotbar selection uses `ChangeSlot(0..8)`
- hotbar slots are commonly `36..44` in inventory slot numbering
- the offhand slot is commonly `45`
- `Container.SearchItem(...)` is the normal way to locate items by type
Good inventory workflow:
1. guard with `GetInventoryEnabled()`
2. read the current container using `GetPlayerInventory()`
3. locate slots with `SearchItem(...)` or `Items`
4. mutate server state using `ChangeSlot(...)`, `WindowAction(...)`, or `ItemMovingHelper`
5. if needed, react to `OnInventoryUpdate(...)`, `OnInventoryOpen(...)`, or `OnInventoryClose(...)`
Plugins and channels:
- `RegisterPluginChannel(channel)`
- `UnregisterPluginChannel(channel)`
- `SendPluginChannelMessage(channel, data, ...)`
## Built-in bot pattern
A built-in bot usually follows this shape:
- a class that inherits `ChatBot`
- an optional static `Config` field
- a nested `[TomlDoNotInlineObject]` `Configs` class for settings
- an `Enabled = false` setting by default
- `OnSettingUpdate()` to normalize or validate config values
If the bot is configurable, the host codebase usually also needs:
- config wiring in the chat-bot config model
- load registration so enabled bots are instantiated automatically
In this MCC checkout, the usual built-in wiring points are:
- `MinecraftClient/Settings.cs` inside `Settings.ChatBotConfigHealper.ChatBotConfig`
- `MinecraftClient/McClient.cs` inside `RegisterBots(...)`
Match the surrounding `[TomlPrecedingComment(...)]`, property-forwarding, and `BotLoad(new YourBot())` style instead of inventing a different config path.
When presenting built-in wiring, prefer literal code snippets or patch hunks for those two edits so the wiring can be checked directly.
If the bot adds user-facing settings or messages, follow the host codebase's localization and config-comment conventions instead of scattering hardcoded strings.
## Command pattern
For standalone script bots, prefer chat or PM handling in `GetText(...)` unless the user explicitly asks for built-in command registration.
For built-in commands, prefer the current Brigadier dispatcher pattern:
- register commands in `Initialize()`
- add a help entry if the bot exposes commands
- unregister the command tree in `OnUnload()`
- remove any help child added during registration in `OnUnload()`
Avoid using legacy command wrappers if the current codebase uses direct dispatcher registration.
In this checkout, treat direct `McClient.dispatcher.Register(...)` usage in current built-in bots as the source of truth.
## Concurrency and cleanup
If the bot starts background work:
- stop it in `OnUnload()`
- stop it in `OnDisconnect(...)`
- consider resetting state in `AfterGameJoined()` after relog
- prefer `Update()` plus counters or timestamps over unmanaged threads when the task is simple periodic work
If the bot controls movement:
- use a movement-lock discipline
- release the lock on every stop path
- avoid fighting other movement bots
- `BotMovementLock` is mainly for built-in bots or shared long-running automation; a simple standalone script that just calls `MoveToLocation(...)` does not need it by default
When interacting with client state from background logic, use the main-thread helpers when required by the codebase.
## Practical defaults
For simple chat bots:
- normalize text with `GetVerbatim(text)`
- inspect private chat first if the bot listens for whispers
- then inspect public chat
- keep response logic small and deterministic
For long-running automation bots:
- guard prerequisites early, such as entity handling or terrain support
- fail fast with a clear log message if prerequisites are missing
- release all ongoing work cleanly on unload and disconnect
## Common pitfalls
- Incorrect metadata line 1 will break standalone script loading.
- Missing `MCC.LoadBot(new BotClassName())` will prevent standalone script registration.
- Sending chat in `Initialize()` is too early.
- Doing prerequisite checks or unloading from the constructor is harder to reason about than using `Initialize()`.
- Parsing raw formatted text without `GetVerbatim()` causes brittle chat matching.
- Inventing methods not present in the MCC ChatBot API leads to dead code.
- Built-in bot work is incomplete if config or registration wiring is missing.
- Command bots are incomplete if they register commands but do not unregister them.
- `RegisterChatBotCommand(...)` comes from older samples and is not a reliable current pattern for this checkout.
- `ChatBotCommand` exists, but the current built-in bots use Brigadier directly; do not prefer `ChatBotCommand` for new work.
- Blocking `Thread.Sleep(...)` inside `Update()` is a bad default. Prefer timers, counters, or timestamp-based scheduling.
- Mutating the `Container` returned by `GetPlayerInventory()` does not change the server. Use inventory actions instead.

View file

@ -0,0 +1,330 @@
# MCC Pattern Cookbook
Concrete patterns for standalone MCC `/script` bots. Use these before inventing new scaffolding.
## Periodic task without threads
Use `Update()` plus a timestamp or counter. This comes from the old `sample-script-with-task.cs` example and still holds up well.
```csharp
public class PeriodicTaskBot : ChatBot
{
private DateTime nextRun = DateTime.MinValue;
public override void Update()
{
var now = DateTime.UtcNow;
if (now < nextRun)
return;
nextRun = now.AddSeconds(30);
LogDebugToConsole("Running periodic task");
SendText("/ping");
}
}
```
Why this pattern is good:
- stays on MCC's normal tick flow
- avoids background threads for simple periodic work
- keeps the bot responsive to unload and disconnect
## Chat and PM handling
This combines the useful parts of `TestBot`, `sample-script-pm-forwarder.cs`, and `RemoteControl.cs`.
```csharp
public override void GetText(string text)
{
text = GetVerbatim(text);
string message = "";
string sender = "";
if (IsPrivateMessage(text, ref message, ref sender))
{
LogToConsole("PM from " + sender + ": " + message);
return;
}
if (IsChatMessage(text, ref message, ref sender))
{
LogToConsole("Chat from " + sender + ": " + message);
}
}
```
Owner-gated internal command handling:
Add `//using MinecraftClient.CommandHandler` in the script metadata if you use `CmdResult`.
```csharp
public override void GetText(string text)
{
text = GetVerbatim(text).Trim();
string command = "";
string sender = "";
if (IsPrivateMessage(text, ref command, ref sender)
&& Settings.Config.Main.Advanced.BotOwners.Contains(sender.ToLowerInvariant()))
{
CmdResult result = new();
PerformInternalCommand(command, ref result);
SendPrivateMessage(sender, result.ToString());
}
}
```
## Movement with prerequisite checks
Modern movement code should copy the guard style from current built-in bots, not the older constructor-heavy scripts.
```csharp
public override void Initialize()
{
if (!GetEntityHandlingEnabled() || !GetTerrainEnabled())
{
LogToConsole("Entity handling and terrain handling are required.");
UnloadBot();
}
}
```
Simple "look at nearest player" logic adapted from `AutoLook.cs`:
```csharp
private Entity? trackedPlayer = null;
public override void OnEntitySpawn(Entity entity)
{
TryTrack(entity);
}
public override void OnEntityDespawn(Entity entity)
{
if (trackedPlayer != null && entity.ID == trackedPlayer.ID)
trackedPlayer = null;
}
public override void OnEntityMove(Entity entity)
{
if (!TryTrack(entity))
return;
LookAtLocation(entity.Location);
}
private bool TryTrack(Entity entity)
{
if (entity.Type != EntityType.Player)
return false;
if (trackedPlayer == null)
{
trackedPlayer = entity;
return true;
}
if (GetCurrentLocation().Distance(entity.Location) < GetCurrentLocation().Distance(trackedPlayer.Location))
trackedPlayer = entity;
return trackedPlayer.ID == entity.ID;
}
```
## Search for dropped items and move to them
This is the safest pattern to preserve from `ItemsCollector.cs` for standalone scripts.
```csharp
public class NearbyItemsBot : ChatBot
{
private DateTime nextScan = DateTime.MinValue;
public override void Initialize()
{
if (!GetEntityHandlingEnabled() || !GetTerrainEnabled())
{
LogToConsole("Entity handling and terrain handling are required.");
UnloadBot();
}
}
public override void Update()
{
var now = DateTime.UtcNow;
if (now < nextScan || ClientIsMoving())
return;
nextScan = now.AddSeconds(1);
var here = GetCurrentLocation();
var target = GetEntities().Values
.Where(entity => entity.Type == EntityType.Item && entity.Location.Distance(here) <= 15)
.OrderBy(entity => entity.Location.Distance(here))
.FirstOrDefault();
if (target != null)
MoveToLocation(target.Location);
}
}
```
Why this version is better than older farming scripts:
- no unmanaged worker thread
- no busy wait loop around movement
- uses the current `GetEntities()` pattern
## Search for blocks or crops in the world
The old sugar cane and mining scripts still contain a useful search idea: use `GetWorld().FindBlock(...)`, then filter and sort.
```csharp
var targets = GetWorld()
.FindBlock(GetCurrentLocation(), Material.SugarCane, 16)
.Where(block =>
GetWorld().GetBlock(new Location(block.X, block.Y - 1, block.Z)).Type == Material.SugarCane)
.OrderBy(block => block.Distance(GetCurrentLocation()))
.ToList();
```
Use this as a search primitive. Then decide separately how to move, dig, or harvest.
## Inventory access and manipulation
If a standalone script uses inventory types directly, add this import in the metadata block:
```csharp
//using MinecraftClient.Inventory
```
For built-in bots, add:
```csharp
using MinecraftClient.Inventory;
```
Always guard inventory logic first:
```csharp
public override void Initialize()
{
if (!GetInventoryEnabled())
{
LogToConsole("Inventory handling is required.");
UnloadBot();
}
}
```
Important rule:
- `GetPlayerInventory()` returns a snapshot copy, so editing its `Items` dictionary does not change the server
- actual changes must go through `ChangeSlot(...)`, `WindowAction(...)`, `GetItemMovingHelper(...)`, `UseItemInHand()`, and related helpers
### Search inventory for an item
This combines the useful current logic from `Farmer.cs` and `AutoEat.cs`.
```csharp
private bool TrySwitchToItem(ItemType itemType)
{
var inventory = GetPlayerInventory();
if (inventory.Items.TryGetValue(GetCurrentSlot() - 36, out var held) && held.Type == itemType)
return true;
var hotbarSlots = inventory.SearchItem(itemType)
.Where(slot => slot >= 36 && slot <= 44)
.ToArray();
if (hotbarSlots.Length == 0)
return false;
ChangeSlot((short)(hotbarSlots[0] - 36));
return true;
}
```
Use this for simple hotbar selection. For deeper inventory reshuffling, built-in bots usually need more helper logic.
### Move an item into the hotbar
Use this when the item exists in inventory but is not already on the hotbar.
```csharp
private bool TryMoveItemToHotbar(ItemType itemType, short targetHotbarSlot = 0)
{
var inventory = GetPlayerInventory();
var matches = inventory.SearchItem(itemType);
if (matches.Length == 0)
return false;
var targetInventorySlot = 36 + targetHotbarSlot;
if (matches[0] >= 36 && matches[0] <= 44)
{
ChangeSlot((short)(matches[0] - 36));
return true;
}
var movingHelper = GetItemMovingHelper(inventory);
movingHelper.Swap(matches[0], targetInventorySlot);
ChangeSlot(targetHotbarSlot);
return true;
}
```
Why this pattern is good:
- it reads the current snapshot first
- it does not pretend local `Container` edits affect the server
- it uses the item-moving helper for real inventory manipulation
### Drop or click items with window actions
Use `WindowAction(...)` when the bot needs direct inventory clicks or dropping behavior.
```csharp
private void DropAllOfType(ItemType itemType)
{
var inventory = GetPlayerInventory();
foreach (int slot in inventory.SearchItem(itemType))
WindowAction(0, slot, WindowActionType.DropItemStack);
}
```
Use this pattern carefully:
- verify the correct inventory ID first
- prefer reacting to `OnInventoryUpdate(...)` for larger inventory workflows
- for crafting or chest workflows, use `GetInventories()` and `CloseInventory(...)` as needed
## Built-in command bot pattern
Only use this when the user explicitly asks for a built-in bot.
```csharp
public override void Initialize()
{
McClient.dispatcher.Register(l => l.Literal("help")
.Then(l => l.Literal(CommandName)
.Executes(r => OnCommandHelp(r.Source, string.Empty))
)
);
McClient.dispatcher.Register(l => l.Literal(CommandName)
.Then(l => l.Literal("_help")
.Executes(r => OnCommandHelp(r.Source, string.Empty))
.Redirect(McClient.dispatcher.GetRoot().GetChild("help").GetChild(CommandName)))
);
}
public override void OnUnload()
{
McClient.dispatcher.Unregister(CommandName);
McClient.dispatcher.GetRoot().GetChild("help").RemoveChild(CommandName);
}
```
Use a built-in bot only when the user explicitly asks for compiled MCC behavior or repo wiring.

View file

@ -0,0 +1,362 @@
---
name: mcc-dev-workflow
description: Build, run, and debug Minecraft Console Client (MCC) against a real local Minecraft Java server on Linux, macOS, or WSL. Use this whenever the user wants to compile MCC, start or inspect a local test server, connect MCC to a server, debug protocol or login issues, validate a code change end-to-end, or run MCC commands on a real server instead of guessing from static code.
---
# MCC Development Workflow
Use this skill when the task needs a real local server loop, not just code reading.
## Defaults
- Solution: `MinecraftClient.sln`
- Runtime target: `.NET 10` / `net10.0`
- Environment: Linux, macOS, or WSL with Java, tmux, python3, and dotnet available
- Default server root after `source tools/mcc-env.sh`: `${MCC_SERVERS:-<repo>/MinecraftOfficial/downloads}`
- Default validation target when the user does not specify a version: `1.21.11`
## Console modes
MCC supports two console modes selectable via `ConsoleMode` in `[Console.General]`:
| Mode | Backend | Best for |
|------|---------|----------|
| `classic` | `ClassicConsoleBackend` (ConsoleInteractive) | Normal use, legacy CI/scripts, `FileInput` mode |
| `tui` | `TuiConsoleBackend` (Avalonia/Consolonia) | Full-screen TUI with scrollable log, command input, popup inventory |
Both modes support the same commands and input/output through `ConsoleIO.Backend`. The mode is determined at startup from config; `BasicIO` CLI arg overrides to simple stdio.
## Core rules
- Prefer a real local server over static reasoning for protocol, login, movement, inventory, entity, or command-path work.
- Treat tmux `mc-*` sessions as shared state. Do not run multi-version server workflows in parallel unless the harness explicitly isolates them.
- For scripted or repeatable runs, use a generated temporary config. Do not edit the repo-root `MinecraftClient.ini` as part of the test loop.
- A server log line containing `Done (` means startup finished. It does not guarantee that RCON is ready on the first attempt. Retry early `mc-rcon` commands.
- When instructions, docs, and code disagree, trust current code and current tool behavior first.
## Shared server, isolated MCC sessions
- `mc-*` commands operate on the shared local Minecraft server.
- `mcc-*` commands operate on one MCC client session.
- The default `session` is the current worktree name.
- The default username is derived from `session`, unless you pass `--username`.
- Session files live under `${TMPDIR:-/tmp}/mcc-debug/<session>/`.
- `MCC_SERVERS` stays the shared server-root override.
Keep shared servers running by default. Do not stop or reset them unless the user explicitly asks for that, or you need to switch server versions.
Two worktrees can debug against one shared server like this:
```bash
# worktree A
cd ~/Minecraft/Minecraft-Console-Client
source tools/mcc-env.sh
mc-start 1.21.11
mcc-debug -v 1.21.11 --file-input
# worktree B
cd ~/Minecraft/Minecraft-Console-Client-foo
source tools/mcc-env.sh
mcc-debug -v 1.21.11 --file-input
# from each worktree, mcc-* targets that worktree's default session
mcc-state
```
If you want two MCC sessions from the same worktree, pass `--session NAME` explicitly.
## tmpfs build mode
Use this on machines with enough RAM when you want worktree-isolated builds outside the repo tree:
```bash
source tools/mcc-env.sh
export MCC_BUILD_MODE=tmpfs
mcc-build
mcc-build-clean
```
`MCC_BUILD_MODE=tmpfs` redirects build output to `/dev/shm/mcc-build/<worktree>/` on Linux, or `${TMPDIR:-/tmp}/mcc-build/<worktree>/` if `/dev/shm` is unavailable.
## Preflight and reset
Before scripted runs, especially on macOS or in a reused tmux environment:
```bash
source tools/mcc-env.sh
mcc-preflight 1.21.11
mc-reset-test-env 1.21.11
```
`mcc-preflight` checks Java, tmux, dotnet, python3, and server directories. It also resolves common Homebrew Java paths on macOS. `mc-reset-test-env` clears stale tmux sessions and stale `stdin.pipe` files before they turn into misleading startup failures.
## Build
```bash
source tools/mcc-env.sh
mcc-build
```
Use `mcc-build` for normal local development so any `MCC_BUILD_MODE=tmpfs` routing stays active. Only use raw `dotnet build` when you are intentionally debugging the build system itself.
## Server management
Interactive shell:
```bash
source tools/mcc-env.sh
SESSION="$(_mcc_resolve_session)"
USERNAME="$(_mcc_resolve_username "$SESSION")"
mc-start 1.21.11
mc-log 1.21.11 100
mc-rcon "op $USERNAME"
mc-stop 1.21.11
```
Non-interactive shell:
```bash
tools/start-server.sh 1.21.11
tools/mc-rcon.sh "op mcc_smoke_a"
```
If the servers live outside the repo, set `MCC_SERVERS` before sourcing or invoking the tools:
```bash
export MCC_SERVERS=/home/anon/Minecraft/Servers
source tools/mcc-env.sh
```
## One-step debug session (recommended)
The `tools/mcc-debug.sh` script handles build, server startup, config preparation, and MCC launch in one step:
```bash
source tools/mcc-env.sh
# Classic mode with FileInput (script-driven debugging):
mcc-debug -v 1.21.11 --file-input
# Classic mode interactive (attach via tmux):
mcc-debug -v 1.21.11
# TUI mode:
mcc-debug -v 1.21.11 -m tui
# With debug messages enabled from start:
mcc-debug -v 1.21.11 --file-input --debug-on
# Skip build (already built):
mcc-debug -v 1.21.11 --file-input --no-build
```
### What mcc-debug.sh does
1. Builds MCC (unless `--no-build`)
2. Creates a clean temp config at `${TMPDIR:-/tmp}/mcc-debug/<session>/MinecraftClient.debug.ini`
3. Ensures server is running (starts if not, waits for `Done (`)
4. Launches MCC in a session-scoped tmux session and session-scoped log/input/pid files
### After launch
- **FileInput mode**: drive MCC via `mcc-cmd --session smoke-a "debug state"`, or just `mcc-cmd "debug state"` from the same worktree
- **Interactive/TUI mode**: attach with `tmux attach -t mcc-<session>`
- **Logs**: `mcc-log-mcc --session smoke-a` or `tail -f "${TMPDIR:-/tmp}/mcc-debug/<session>/mcc-debug.log"`
- **Server RCON**: grant op or gamemode to the username derived from that session
## Debug commands (in-game)
### `/debug [on|off]`
Toggles debug logging. Now correctly syncs both `Settings.Config.Logging.DebugMessages` and `McClient.Log.DebugEnabled`.
### `/debug state`
Prints a one-shot summary of MCC's internal state:
```
=== MCC Debug State ===
Server: localhost:25565
Username: mcc_smoke_a
Protocol: 774
GameMode: 1
Health: 20.0
Food: 20
Location: 0.50, 80.00, 0.50
TPS: 20.0
Console: ClassicConsoleBackend (or TuiConsoleBackend)
Features: Terrain Inventory Entity
Debug: ON
Bots (3): AutoFishing, FileInputBot, ScriptScheduler
Players: 2 online
```
This works in both classic and TUI modes.
## Classic mode debugging
### Agent workflow (FileInput mode)
For agents calling MCC commands programmatically:
```bash
source tools/mcc-env.sh
SESSION="smoke-a"
mcc-debug -v 1.21.11 --file-input --session "$SESSION" --no-build
# Send commands:
mcc-cmd --session "$SESSION" "debug state"
mcc-cmd --session "$SESSION" "inventory player list"
mcc-cmd --session "$SESSION" "entity"
# Check results:
mcc-log-mcc --session "$SESSION"
# Stop:
mcc-cmd --session "$SESSION" "quit"
mcc-kill --session "$SESSION"
mc-stop 1.21.11
```
### Interactive workflow
```bash
source tools/mcc-env.sh
SESSION="live-a"
mcc-debug -v 1.21.11 --session "$SESSION"
# In another terminal:
tmux attach -t "mcc-$SESSION"
# Type commands directly in MCC console
```
## TUI mode debugging
TUI mode runs Consolonia full-screen in a tmux session. Key differences:
1. **No pipe/redirect**: TUI needs a real tty. Cannot `| tee` or redirect stdout.
2. **Log output is in-screen**: all output appears in the scrollable log area.
3. **Keyboard shortcuts**: PageUp/PageDown scroll, ESC exits.
4. **`/debug state`**: the primary way to inspect internal state since external log tailing is not available.
5. **Dialog windows**: `/inventui` opens as an overlay dialog instead of a separate screen.
### Agent workflow for TUI mode
```bash
source tools/mcc-env.sh
SESSION="tui-a"
mcc-debug -v 1.21.11 -m tui --session "$SESSION" --no-build
# Cannot use mcc-cmd (no FileInput); must use tmux send-keys:
tmux send-keys -t "mcc-$SESSION" "/debug state" Enter
# Read TUI screen:
tmux capture-pane -t "mcc-$SESSION" -p -S -30
# Stop:
tmux send-keys -t "mcc-$SESSION" Escape
```
**Caveat with tmux send-keys and Consolonia**: When sending text containing `/`, the Enter key may need to be sent separately:
```bash
tmux send-keys -t "mcc-$SESSION" "/inventory player list"
tmux send-keys -t "mcc-$SESSION" Enter
```
## mcc-env.sh quick reference
After `source tools/mcc-env.sh`:
| Function | Description |
|----------|-------------|
| `mc-start VER` | Start MC server in tmux |
| `mc-stop VER` | Graceful stop via stdin pipe |
| `mc-log VER [N]` | Capture last N lines of server output |
| `mc-rcon "CMD"` | Send RCON command |
| `mc-kill VER` | Force-kill server tmux session |
| `mc-list` | List running MC server sessions |
| `mc-wait-ready VER [SEC]` | Wait for server `Done (` |
| `mc-wait-stop VER [SEC]` | Wait for server shutdown, with force-kill fallback |
| `mc-reset-test-env [--all|VER...]` | Reset shared tmux server state and stale pipes |
| `mcc-build` | Build MCC |
| `mcc-publish --rid <RID>` | Publish MCC with the repo's CI-like defaults |
| `mcc-build-clean` | Clear the current worktree's build output |
| `mcc-run [--session NAME] [--username NAME] [--port PORT]` | Convenience wrapper for `mcc-debug --file-input --no-build` |
| `mcc-tui [--session NAME] [--username NAME] [--port PORT]` | Convenience wrapper for `mcc-debug -m tui --no-build` |
| `mcc-cmd [--session NAME] "CMD"` | Append a command to one session's input file |
| `mcc-kill [--session NAME]` | Kill one MCC process and session |
| `mcc-debug [OPTS]` | One-step debug session (see above) |
| `mcc-log-mcc [--session NAME]` | Tail one MCC debug log |
| `mcc-state [--session NAME]` | Send `debug state` and print the last 30 log lines |
| `mcc-preflight [VER...]` | Verify Java, tmux, dotnet, python3, and server dirs |
## Temporary config recipe
```bash
source tools/mcc-env.sh
SESSION="smoke-a"
USERNAME="$(_mcc_resolve_username "$SESSION")"
CFG="$(_mcc_session_root "$SESSION")/MinecraftClient.debug.ini"
mkdir -p "$(_mcc_session_root "$SESSION")"
bash "$MCC_REPO/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh" \
"$CFG" \
"1.21.11" \
"$USERNAME"
```
For TUI mode, also add:
```bash
sed -i 's/ConsoleMode = "classic"/ConsoleMode = "tui"/' "$CFG"
```
## Verify connection and a basic command
MCC output should include:
- `[MCC] Server was successfully joined.`
Server output should include the session-derived username, for example:
- `mcc_smoke_a joined the game`
Basic command check:
```bash
mcc-cmd --session smoke-a "inventory player list"
```
If a scripted run fails before MCC joins, check for a harness problem before assuming a product regression. Missing `mcc.log`, a pre-join `Connection refused`, or a server that never reached `Done (` usually means shared-state cleanup or startup failed.
## Typical debug loop
1. `source tools/mcc-env.sh`
2. `mcc-debug -v 1.21.11 --file-input` (or `-m tui`)
3. Confirm `Server was successfully joined` in log
4. `mcc-cmd "debug state"` to verify MCC state
5. Run test commands
6. Inspect log output
7. `mcc-cmd "quit"` and `mc-stop 1.21.11`
8. Edit code, rebuild, repeat
## Debugging tips
- **`/debug state` is your primary diagnostic tool** in both modes. Use it first to verify connection, mode, and feature flags.
- **`/debug on` now correctly enables debug logging** at runtime. Previous versions had a bug where `Log.DebugEnabled` was not synced.
- Protocol mismatches usually show up as a version line such as `Server version : 1.21.11 (protocol vNNN)` before the failure.
- If an early `mc-rcon` command fails, retry it before assuming the server setup is broken.
- If a supposedly isolated run behaves strangely, check `tmux list-sessions` and kill stale `mc-*` sessions first.
- Legacy `1.8` and `1.8.9` servers may need `use-native-transport=false` in `server.properties` on some Linux environments.
- For timing-sensitive work, do not trust wall-clock intuition. Use a real server run and capture evidence from logs or test scripts.
- **TUI mode tip**: if the terminal becomes unresponsive after a crash, run `stty sane && reset` to restore it.
- **tmux capture trick**: `tmux capture-pane -t mcc-<session> -p -S -50` captures the last 50 lines of a tmux session without attaching.
## Tool files
| File | Purpose |
|------|---------|
| `tools/mcc-env.sh` | Shell functions for server/MCC management |
| `tools/mcc-debug.sh` | One-step debug session launcher |
| `tools/mcc-log-tail.sh` | Log tailing for MCC and/or server |
| `tools/start-server.sh` | Server lifecycle in tmux |
| `tools/mc-rcon.sh` | RCON command sender |
| `tools/run-creative-e2e.sh` | Full creative mode end-to-end test |

View file

@ -0,0 +1,41 @@
{
"skill_name": "mcc-dev-workflow",
"evals": [
{
"id": 1,
"prompt": "Build MCC, start the local 1.21.11 vanilla server from an MCC_SERVERS root, connect MCC with a temporary config, and verify a successful join plus one inventory command.",
"expected_output": "The workflow uses a real local server, a temp MCC config, and concrete log evidence for both the join and the MCC command.",
"files": [],
"expectations": [
"The workflow uses a real local 1.21.11 server instead of only reading code.",
"The workflow uses MCC_SERVERS-aware tooling or documents the server root explicitly.",
"The workflow uses a temporary MCC config instead of relying on the repo-root config.",
"The result includes join evidence from MCC output and the server log."
]
},
{
"id": 2,
"prompt": "Debug a flaky local MCC startup on 1.21.11 by checking for stale tmux server sessions, waiting for server readiness, and retrying early RCON commands before blaming protocol code.",
"expected_output": "The response treats tmux sessions as shared state, distinguishes server startup from RCON readiness, and uses the real local workflow rather than pure speculation.",
"files": [],
"expectations": [
"The workflow checks or mentions stale mc-* tmux sessions.",
"The workflow distinguishes Done from RCON readiness.",
"The workflow retries or advises retrying early RCON commands.",
"The workflow keeps the debugging loop grounded in real local commands."
]
},
{
"id": 3,
"prompt": "Run a repeatable local MCC debug loop for 1.21.11 that compiles the client, connects with movement, inventory, and entity handling enabled, and leaves enough evidence to inspect a regression afterward.",
"expected_output": "The response follows a real build-run-test-inspect loop and captures enough log evidence to support follow-up debugging.",
"files": [],
"expectations": [
"The workflow builds MCC before the run.",
"The workflow enables terrain, inventory, and entity handling for the scripted run.",
"The workflow captures or points to concrete log locations.",
"The workflow prefers a temp config for repeatability."
]
}
]
}

View file

@ -0,0 +1,323 @@
---
name: mcc-integration-testing
description: >-
Use when proving MCC behavior on a real local Minecraft server, validating
runtime or protocol changes end-to-end, exercising movement, physics,
inventory, entity, chat, or terrain behavior, or running a single-version or
cross-version regression sweep.
metadata:
category: discipline
triggers:
- integration test
- real server
- local server
- regression sweep
- rcon
- tmux
- offline mode
- online mode
- movement
- physics
- inventory
- entity
- terrain
- chat
---
# MCC Integration Testing
Use this skill when the task is "prove it on a real server", not just "reason about whether it should work."
Read [references/online-mode.md](references/online-mode.md) when the user asks for Microsoft login, device-code auth, or an online-mode server run. Use [references/command-matrix.md](references/command-matrix.md) for stable MCC-side and RCON-side commands.
## Iron Law
Only say MCC was integration tested when MCC ran against a real local server and the claim is backed by real MCC output plus real server logs.
Calling build-only, reasoning-only, or join-only work "integration tested" is a rules violation, not shorthand.
These do not count as end-to-end proof:
- static reasoning, source comparison, or build success
- join or login success by itself
- a long-lived idle connection by itself
- a grep that only says there were no errors
- testing one shared-route version and silently claiming adjacent versions also passed
If the environment cannot run a real server, say so and report the result as unexecuted or inferred, not integration tested.
## Default target
- Use `1.21.11-Vanilla` unless the user asks for a different version or a version matrix.
- Use `MCC_SERVERS` if it is set. Otherwise the default server root is `MinecraftOfficial/downloads`.
## Guardrails
- Use a real local server.
- Launch MCC against an explicit `localhost:<server-port>` target for repeatable local tests.
- Keep version matrices sequential in shared local environments. The tmux server harness is shared state by default.
- Prefer generated temporary MCC configs for scripted runs so one test does not contaminate the next.
- Default to offline auth in generated temp configs. Do not trust the repo-root `MinecraftClient.ini` account defaults.
- If the user explicitly asks for Microsoft online login, honor that request and generate the temp config for Microsoft auth instead of offline mode.
- For Microsoft auth, prefer an interactive TTY launch with `BasicIO-NoColor` so the device code is easy to read and relay to the user.
- Do not use file-input mode during Microsoft auth. Launch interactively first, complete login, then switch to scripted control only if needed.
- For online-mode tests, prefer a clean temp config with no join-time bots or scheduled tasks. Inherited `ScriptScheduler` or `DiscordRpc` settings can pollute the session and send unintended chat right after login.
- Legacy and modern command syntax differ. Do not assume one server-command profile fits every version.
- Use actual MCC output and actual server logs for assertions. Do not invent success strings.
- Treat server `Done` as startup progress, not RCON readiness. Retry the first RCON command before assuming the setup is broken.
- Run preflight before scripted test loops. On macOS, Java may be installed but not exported on PATH in the shell the harness uses.
- If a change touches shared routing or a version range, test at least one adjacent version that shares that path, or explicitly mark adjacent versions as unexecuted and inferred.
- For palette or version-content changes, probe at least one neighboring or existing item, entity, or block. Do not only check the headline addition.
- Separate product failures from harness failures. Missing logs, stale tmux state, stale `stdin.pipe`, or pre-join `Connection refused` errors are usually environment problems until proven otherwise.
## Choose the test mode
### 1. Single-version deep smoke
Use this when one supported version is enough and you want broad coverage:
```bash
.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh 1.21.11-Vanilla
```
This covers join, chat, slash commands, internal MCC commands, creative inventory, entity handling, sounds, particles, TNT, kill/respawn, and log assertions.
### 2. Ordered creative-mode E2E
Use this when the user asks for a regression sweep in a strict scenario order such as:
- connect
- send messages
- send commands
- receive messages
- movement
- physics
- mobs
- effects
- inventory
Broad validation should usually cover `connect-test`, `item-test`, `entity-test`, `terrain-test`, and `chat-test`.
Command:
```bash
MCC_SERVERS=/home/anon/Minecraft/Servers bash tools/run-creative-e2e.sh 1.21.11-Vanilla 1.21.11 modern
```
For legacy targets such as `1.8` or `1.8.9`, switch the final argument to `legacy` and pass the pinned MC version.
### 3. Timing or cadence validation
Use this for TPS, movement-cadence, or packet-cadence work:
- `MinecraftClient/config/sample-script-tick-counter.cs`
- `MinecraftClient/config/sample-script-packet-capture.cs`
Run them against a real server with a temp config and summarize counts from the captured logs.
### 4. Structured components test
Use this after touching any `StructuredComponents` code (registries, component
parsers, subcomponents, codec helpers) to prove every component type in a
version parses on the wire without error:
```bash
bash tools/run-structured-components-test.sh 1.21.11
```
Run a single version (fast, ~2 min) or a matrix:
```bash
for v in 1.20.6 1.21 1.21.2 1.21.5 1.21.11 26.1; do
bash tools/run-structured-components-test.sh "$v"
done
```
The script gives items with every registered component via RCON `/give`, reads
them back with `inventory player list`, and asserts no parse errors in the MCC
log. Version-gated components (v1212+, v1215+, v12111+, v261) are tested only
on the versions that support them. See `SC_Integration_Test_Report.md` for a
reference run across all 6 version groups.
### 6. Dialog integration test
Use this after touching any dialog system code (packet handling, NBT parsing,
models, TUI, command dispatch, the state machine in `DialogManager`, or the
codec in `DialogNbtParser`). Tests all 5 dialog types, button actions (close,
run_command, show_dialog), cancel/dismiss, click-label, and body content:
```bash
tools/run-dialog-test.sh 26.1
```
The script starts the server if needed, generates a temp MCC config, launches
MCC with file-input mode (requires both `MCC_FILE_INPUT=1` and
`MCC_INPUT_FILE=<path>` env vars), sends inline SNBT dialogs via RCON, and
asserts 29 checks against the MCC log.
Key requirements that differ from other test modes:
- FileInputBot is loaded only when `MCC_FILE_INPUT=1` is set in the
environment. The `[ChatBot.FileInput]` config section is ignored at load
time.
- The input file path is controlled by `MCC_INPUT_FILE`, *not* by the config
`File` setting.
- Dialogs use inline SNBT syntax through `ResourceOrIdArgument`, e.g.:
`dialog show <player> {type:"minecraft:notice", title:{text:"Hello"}}`
- The `ActionButton.CODEC` flattens `CommonButtonData` fields (`label`,
`tooltip`, `width`) into the same object as `action` — no `button` wrapper.
### 5. Full inventory regression sweep
Use this when touching inventory snapshots, player/container slot sync, creative inventory, item-slot serialization, packet palettes, game-mode updates, or block-use paths that open containers:
```bash
tools/run-inventory-full-sweep.sh --versions "1.21.10 1.21.11"
```
Default coverage includes:
- player inventory listing and inventory discovery
- creative give/delete
- inventory search
- player right/left click stack split and merge
- player drop one and drop all
- chest open via `useblock`
- container listing and close
- mirrored player slots in container windows
- shift-click and shift-right-click transfer
- container right/left click, cursor stack, drop one, and drop all
- creative middle-click command path
- log scan for packet parse failures, queue-empty crashes, unhandled exceptions, and disconnects
The script writes `summary.tsv` under `RUN_ROOT` and per-version logs under `/tmp/mcc-debug/inventory-full-<version>/mcc-debug.log`.
When a matrix has existing PASS rows, do not rerun them unless a later code change affects that row or the user asks for a full rerun. Derive remaining rows from summaries:
```bash
awk 'FNR>1 && $2=="PASS" {print $1}' /tmp/mcc-inventory-full-sweep/*/summary.tsv | sort -V | uniq
```
## Preconditions
Before running any scenario:
0. run preflight and clear stale shared state when the environment is reused
1. configure the target server for offline testing
2. ensure `eula=true`
3. ensure RCON is enabled
4. build MCC unless the task explicitly reuses a fresh build
Preflight and reset helpers:
```bash
.skills/mcc-integration-testing/scripts/preflight_test_env.sh 1.21.11-Vanilla
.skills/mcc-integration-testing/scripts/reset_shared_test_state.sh 1.21.11-Vanilla
```
Offline configuration helper:
```bash
.skills/mcc-integration-testing/scripts/ensure_offline_server.sh 1.21.11-Vanilla
```
By default, the config helper prepares offline auth. To opt into another auth mode for a specific run, set:
```bash
MCC_TEST_ACCOUNT_TYPE=microsoft
MCC_TEST_PASSWORD=
```
Optionally override the login name with the fourth argument to the config helper.
## Scripts and tools
- `.skills/mcc-integration-testing/scripts/ensure_offline_server.sh`
- configures persistent offline mode and RCON
- `.skills/mcc-integration-testing/scripts/preflight_test_env.sh`
- verifies Java, tmux, dotnet, python3, server directories, and resolves common Java PATH issues
- `.skills/mcc-integration-testing/scripts/reset_shared_test_state.sh`
- clears stale tmux sessions and stale `stdin.pipe` files before a rerun
- `.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh`
- generates a clean temporary MCC config, prepares offline login by default, disables noisy bots, and can switch to Microsoft auth when explicitly requested
- `.skills/mcc-integration-testing/scripts/get_server_port.sh`
- resolves the actual local server port from `server.properties` or the latest server log
- `.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh`
- single-version deep smoke with built-in assertions
- `.skills/mcc-integration-testing/scripts/summarize_test_run.sh`
- summarize the latest full-spectrum run
- `tools/run-creative-e2e.sh`
- ordered creative-mode E2E regression scenario
- `tools/run-inventory-full-sweep.sh`
- full inventory command/API sweep across one or more versions
- `tools/run-structured-components-test.sh`
- exercises every structured component via RCON `/give` across versions 1.20.6-26.1
- `tools/run-dialog-test.sh`
- dialog integration test: all 5 types, run_command/show_dialog actions,
cancel/dismiss/click-label, body content; 29 assertions on MCC log
## Evidence Discipline
In every report, separate:
- `Executed`: exact scripts, commands, versions, auth mode, and whether the run was sequential or single-version
- `Observed`: exact MCC output, exact server-log evidence, and the saved log directory
- `Inferred`: conclusions not directly shown by that run's runtime evidence
- `Harness issues`: setup or runner problems such as missing Java on PATH, stale tmux sessions, stale `stdin.pipe`, missing log artifacts, or failed config generation
Never upgrade inferred claims to observed facts. Absence of errors is supporting evidence only; pair it with a positive assertion for the feature under test.
## Red Flags
Stop and fix the test plan if you are about to:
- claim movement, inventory, entity, terrain, physics, or chat coverage from join success alone
- reuse repo-root `MinecraftClient.ini` or another user-local stateful config
- run multi-version tests in parallel in a shared tmux or shared server environment
- let inherited bots, schedulers, or other user-local noise send chat or commands during validation
## What to report back
Always summarize:
- which version or versions were tested
- which port or ports were used
- which auth mode and scenario were used
- whether the run was sequential or single-version
- the exact scripts or commands executed
- pass or fail per major phase
- concrete evidence from MCC and server logs
- the saved log directory
- what was not executed and what remains inferred
- which adjacent versions were not run but were mentioned
## When Not to Use
- build-only verification
- static protocol or source comparison with no real server run
- documentation or prompt work
- code review requests that do not ask for executed runtime proof
## Troubleshooting
- If the first RCON command fails, retry it before assuming the setup is broken.
- If Java is installed but the harness still says it is missing, run `preflight_test_env.sh`. This resolves common Homebrew Java paths on macOS.
- If MCC reaches Microsoft device-code login during an offline test, stop and inspect the generated temp config before retrying.
- If the user explicitly requests Microsoft online login, set `MCC_TEST_ACCOUNT_TYPE=microsoft` before launching the harness.
- If the user explicitly requests Microsoft online login, use `BasicIO-NoColor` in a real TTY, relay the device code from the TUI, and avoid pressing empty Enter at any auth prompt.
- If the online-mode session sends unexpected chat or commands right after join, inspect inherited bot settings first. `ChatBot.ScriptScheduler` tasks and `ChatBot.DiscordRpc` are common sources of test noise in user-local configs.
- If `dotnet run` cannot see an existing Microsoft session, check whether `SessionCache.db` and `ProfileKeyCache.ini` need to be synced from `MinecraftClient/bin/Release/net10.0/` to the repo root.
- If Microsoft auth keeps prompting even with a valid session cache, verify `Account.Login` matches the cached username exactly.
- If MCC reports `Connection refused`, verify the launched target matches the server's actual `server-port`.
- If MCC reports `Connection refused` immediately after a server start, also check for stale shared state: old tmux sessions, a stale `stdin.pipe`, or a server that never actually reached `Done (`.
- If multiple versions are being tested, do not start them in parallel unless the harness isolates tmux sessions and input files.
- If a test assertion fails, inspect the real MCC output before changing the code or weakening the assertion.
- If an older server behaves oddly on Linux, check `use-native-transport=false` in `server.properties`.
- If a matrix row fails before producing `mcc.log` or a command transcript, treat it as a harness failure, fix the environment, and rerun that row before drawing product conclusions.
- If creative inventory commands report "You must be in Creative gamemode" after RCON switched the player, inspect game-mode update parsing before assuming creative inventory is broken. Modern servers can update local game mode through game event reason `3`.
- If an inventory row crashes with `Queue empty` or `Failed to process incoming packet`, inspect packet palette routing before changing inventory code. A single shifted packet ID can make a healthy inventory feature look broken.
- For chest-open failures, separate product and harness causes. The player may be standing inside the chest or suffocating on older servers. Stand beside the chest, put a floor under the player, and retry `useblock`.
- For shared local servers, a `Done` log line does not prove RCON is ready. Retry setup commands and verify the actual RCON port from `server.properties`.
- If `tools/run-dialog-test.sh` fails with "FileInput Watching: .../mcc_input.txt" pointing to the wrong directory, the `MCC_INPUT_FILE` env var was not set in the tmux command. FileInputBot ignores the config `File` setting entirely.
- If inline SNBT dialogs fail on the server side (`Failed to parse structure: No key ...`), check whether `ActionButton.CODEC` fields are flat (no `button` wrapper) and whether the dialog type fields match the 26.1 server (`label` not `text` in `CommonButtonData`).
- If a dialog integration test fails on "Server showed custom dialog", the dialog packet (id=0x8C in 26.1 play phase) may not have been sent. Verify the RCON command succeeded and the server printed "Displayed dialog to ...".

View file

@ -0,0 +1,41 @@
{
"skill_name": "mcc-integration-testing",
"evals": [
{
"id": 1,
"prompt": "Run a real 1.21.11 MCC regression test in creative mode covering connect, send messages, send commands, receive messages, movement, physics, mobs, effects, and inventory, in that order.",
"expected_output": "The workflow uses the ordered creative-mode E2E harness on a real server, reports pass or fail for each phase, and points to the saved logs.",
"files": [],
"expectations": [
"The workflow uses a real 1.21.11 server.",
"The workflow uses the ordered creative E2E harness instead of improvising the whole scenario.",
"The result reports phase-by-phase outcomes in the requested order.",
"The result includes the log directory for the run."
]
},
{
"id": 2,
"prompt": "Validate that MCC still works after a runtime or protocol change by running a deep single-version 1.21.11 integration test with chat, creative inventory, entity tracking, sounds, particles, TNT, and kill/respawn coverage.",
"expected_output": "The workflow uses the full-spectrum test runner on a real server and returns a concise pass or fail summary backed by MCC and server log evidence.",
"files": [],
"expectations": [
"The workflow builds MCC before the scenario unless a fresh build is explicitly reused.",
"The workflow uses the full-spectrum runner instead of only manual spot checks.",
"The result includes evidence from both MCC output and server logs.",
"The result points to the saved run directory."
]
},
{
"id": 3,
"prompt": "Validate a timing-sensitive MCC change on a real 1.21.11 server by collecting tick-rate and outbound packet-cadence evidence, then summarize the results clearly.",
"expected_output": "The response uses a real server, a temp config, and the provided sample scripts to capture tick-rate and packet evidence instead of relying on intuition.",
"files": [],
"expectations": [
"The workflow uses the real 1.21.11 server.",
"The workflow uses the tick counter and packet capture scripts or clearly equivalent targeted instrumentation.",
"The workflow keeps the run isolated with a temp config.",
"The summary reports concrete counts or cadence evidence."
]
}
]
}

View file

@ -0,0 +1,91 @@
# Command Matrix
This skill uses a fixed set of stable commands for local offline integration testing.
## MCC-side commands via `mcc-cmd`
- `health`
- `list`
- `inventory player list`
- `/gamemode creative`
- `inventory creativegive 36 Diamond 16`
- `inventory creativegive 37 IronSword 1`
- `inventory creativegive 38 GoldenApple 8`
- `inventory creativeclear 38`
- `entity`
- `/time query daytime`
- `look up`
- `look down`
- `look east`
- `/gamemode survival`
- `respawn`
- `/tp MCCBot 0 -60 0`
- `smoke_test_from_mcc_full_spectrum`
- `integration_test_chat_response`
Notes:
- Lines starting with `/` are sent to the server as chat/commands.
- Non-slash lines are treated as MCC internal commands first, then fall back to chat.
## Server-side commands via `mc-rcon`
- `op MCCBot`
- `gamerule sendCommandFeedback true`
- `gamerule logAdminCommands true`
- `time set day`
- `weather clear`
- `say Hello from the server console`
- `msg MCCBot This is a private whisper`
- `effect give MCCBot minecraft:speed 30 1`
- `effect give MCCBot minecraft:regeneration 10 1`
- `kill MCCBot`
## Representative entity coverage
- `execute as MCCBot at @s run summon minecraft:cow ~2 ~ ~`
- `execute as MCCBot at @s run summon minecraft:zombie ~4 ~ ~`
- `execute as MCCBot at @s run summon minecraft:creeper ~6 ~ ~`
- `execute as MCCBot at @s run summon minecraft:skeleton ~8 ~ ~`
- `execute as MCCBot at @s run summon minecraft:villager ~-2 ~ ~`
- `execute as MCCBot at @s run summon minecraft:allay ~-4 ~ ~`
- `execute as MCCBot at @s run summon minecraft:armor_stand ~ ~ ~2`
- `execute as MCCBot at @s run summon minecraft:item_display ~-6 ~ ~ {item:{id:"minecraft:diamond",count:1}}`
- `execute as MCCBot at @s run summon minecraft:spider ~10 ~ ~`
- `execute as MCCBot at @s run summon minecraft:pig ~-8 ~ ~`
## Block placement coverage
- `execute as MCCBot at @s run fill ~1 ~ ~1 ~3 ~2 ~3 minecraft:stone`
- `execute as MCCBot at @s run setblock ~5 ~ ~5 minecraft:chest`
- `execute as MCCBot at @s run setblock ~5 ~1 ~5 minecraft:furnace`
- `execute as MCCBot at @s run setblock ~6 ~ ~5 minecraft:crafting_table`
## Dimension change coverage
- `execute in minecraft:the_nether run tp MCCBot 0 64 0`
- `execute in minecraft:overworld run tp MCCBot 0 -60 0`
## Representative particle coverage
- `execute as MCCBot at @s run particle minecraft:happy_villager ~ ~1 ~ 0.5 0.5 0.5 0 12 force`
- `execute as MCCBot at @s run particle minecraft:end_rod ~ ~1 ~ 0.5 0.5 0.5 0.01 20 force`
- `execute as MCCBot at @s run particle minecraft:explosion ~ ~1 ~ 0 0 0 0 1 force`
- `execute as MCCBot at @s run particle minecraft:totem_of_undying ~ ~1 ~ 0.5 0.5 0.5 0.1 20 force`
- `execute as MCCBot at @s run particle minecraft:flame ~ ~1 ~ 0.2 0.2 0.2 0.02 30 force`
- `execute as MCCBot at @s run particle minecraft:heart ~ ~2 ~ 0.3 0.3 0.3 0 5 force`
## Representative sound coverage
- `execute as MCCBot at @s run playsound minecraft:entity.lightning_bolt.thunder master MCCBot ~ ~ ~ 1 1 0`
- `execute as MCCBot at @s run playsound minecraft:block.note_block.bell master MCCBot ~ ~ ~ 1 1 0`
- `execute as MCCBot at @s run playsound minecraft:entity.experience_orb.pickup master MCCBot ~ ~ ~ 1 1 0`
## Explosion coverage
- `execute as MCCBot at @s run summon minecraft:tnt ~3 ~ ~`
- `execute as MCCBot at @s run summon minecraft:tnt ~6 ~ ~`
## Kill and respawn cycle
- `kill MCCBot` (via RCON, requires survival mode)
- `respawn` (via MCC command after death)

View file

@ -0,0 +1,48 @@
# Online-Mode Notes
Use this flow only when the user explicitly asks for Microsoft login or wants to validate against an online-mode server.
## Launch mode
- Prefer `BasicIO-NoColor` in a real TTY so the Microsoft device-code prompt is easy to read and copy.
- Do not use `MCC_FILE_INPUT=1` during the auth step. It is for scripted command injection, not interactive login.
- Avoid `nohup`. Use `tmux` for long-running sessions that still need a TTY.
- Start from a clean temp config when possible. If the temp config is copied from a user-local `MinecraftClient.ini`, inspect `ChatBot.ScriptScheduler` and `ChatBot.DiscordRpc` before the run.
## Session cache behavior
- `dotnet run --project MinecraftClient ...` uses the repo root for `SessionCache.db` and `ProfileKeyCache.ini`.
- The compiled binary under `MinecraftClient/bin/<Config>/net10.0/` uses that output directory instead.
- If a session exists in one location and not the other, sync the cache files before assuming login is broken.
## Account settings
- `Account.Login` must be populated for MCC to look up a cached Microsoft session.
- The cached key is the username form MCC stored, typically the lowercase username, not necessarily the email address.
- MCC rewrites `MinecraftClient.ini` on clean exit, so generate a temp config per run and do not edit it while MCC is still running.
## Auth prompt handling
- Do not send a bare Enter to dismiss `Password(invisible):` or `Paste your code here:` prompts. That can trigger offline fallback.
- For interactive online-mode runs, wait for the device code prompt and relay the code to the user exactly as shown.
- After the user completes login, continue the test in the same TTY session or restart into file-driven mode if the workflow requires automation.
## Join-time noise
- Real user configs may contain enabled bots or task lists that were harmless in offline testing but are noisy in online-mode validation.
- The most common examples are:
- `ChatBot.ScriptScheduler` task lists that send `/hello`, `/login ...`, or other automatic commands on login or on an interval
- `ChatBot.DiscordRpc`, which is not harmful to server state but adds log noise and extra background activity
- If the goal is protocol or feature validation, suppress these before the run or treat their output as non-test noise.
## Server settings
- For realistic online-mode testing, keep `online-mode=true`.
- Keep `enforce-secure-profile=true` unless the test explicitly targets insecure-profile behavior.
## Command reminders
- With `InternalCmdChar = "slash"`:
- `/health`, `/pos`, `/inventory`, `/entity` are MCC internal commands.
- `/send /list` and `/send /give ...` are server commands.
- bare text is regular chat sent to the server.

View file

@ -0,0 +1,110 @@
#!/usr/bin/env bash
sed_in_place() {
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "$@"
else
sed -i "$@"
fi
}
ensure_java_in_path() {
if command -v java >/dev/null 2>&1 && java -version >/dev/null 2>&1; then
return 0
fi
local candidate
for candidate in \
"${JAVA_BIN:-}" \
"/opt/homebrew/opt/openjdk/bin/java" \
"/usr/local/opt/openjdk/bin/java" \
"/usr/lib/jvm/default-java/bin/java"
do
[[ -z "$candidate" ]] && continue
if [[ -x "$candidate" ]]; then
export PATH="$(dirname "$candidate"):$PATH"
export JAVA_BIN="$candidate"
if java -version >/dev/null 2>&1; then
return 0
fi
fi
done
echo "java was not found on PATH. Install Java or set JAVA_BIN." >&2
return 1
}
server_session_name() {
printf 'mc-%s\n' "${1//./_}"
}
server_running() {
local version="$1"
mc-list | grep -Fq "$(server_session_name "$version")"
}
wait_for_server_ready() {
local version="$1"
local timeout="${2:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if mc-log "$version" 250 2>/dev/null | grep -Fq "Done ("; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for $version to become ready" >&2
return 1
}
wait_for_server_stop() {
local version="$1"
local timeout="${2:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if ! server_running "$version"; then
return 0
fi
sleep 1
((elapsed += 1))
done
mc-kill "$version" --confirm >/dev/null 2>&1 || true
if ! server_running "$version"; then
return 0
fi
echo "Timed out waiting for $version to stop" >&2
return 1
}
disable_noisy_bots_in_ini() {
local ini_file="$1"
local section
for section in \
ScriptScheduler \
DiscordRpc \
AntiAFK \
AutoDig \
AutoAttack \
PlayerListLogger \
ReplayCapture
do
sed_in_place "/^\\[ChatBot\\.${section}\\]/,/^\\[/ { s/^Enabled = true/Enabled = false/; }" "$ini_file"
done
}
remove_stale_stdin_pipe() {
local version="$1"
local pipe_path="$MCC_SERVERS/$version/stdin.pipe"
if [[ -e "$pipe_path" ]] && ! server_running "$version"; then
rm -f "$pipe_path"
fi
}

View file

@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=tools/mcc-env.sh
source "$REPO_ROOT/tools/mcc-env.sh"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
VERSION="${1:-1.21.11-Vanilla}"
SERVER_DIR="${MCC_SERVERS:?}/$VERSION"
PROPS_FILE="$SERVER_DIR/server.properties"
SESSION_NAME="mc-${VERSION//./_}"
if [[ ! -d "$SERVER_DIR" ]]; then
echo "Server directory not found: $SERVER_DIR" >&2
exit 1
fi
if [[ ! -f "$SERVER_DIR/eula.txt" ]] || ! grep -Eq '^eula=true$' "$SERVER_DIR/eula.txt"; then
echo "Missing accepted EULA in $SERVER_DIR/eula.txt" >&2
exit 1
fi
server_running() {
mc-list | grep -Fq "$SESSION_NAME"
}
upsert_property() {
local key="$1"
local value="$2"
if grep -Eq "^${key}=" "$PROPS_FILE"; then
sed_in_place "s#^${key}=.*#${key}=${value}#" "$PROPS_FILE"
else
printf '%s=%s\n' "$key" "$value" >> "$PROPS_FILE"
fi
}
if [[ ! -f "$PROPS_FILE" ]]; then
mc-start "$VERSION"
wait_for_server_ready "$VERSION"
mc-stop "$VERSION" --confirm
wait_for_server_stop "$VERSION"
fi
if server_running; then
mc-stop "$VERSION" --confirm
wait_for_server_stop "$VERSION"
fi
upsert_property "online-mode" "false"
upsert_property "enforce-secure-profile" "false"
upsert_property "enable-rcon" "true"
upsert_property "rcon.port" "25575"
upsert_property "rcon.password" "test123"
echo "Configured $VERSION for persistent offline testing"

View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <server-dir>" >&2
exit 1
fi
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
SERVER_DIR_NAME="$1"
SERVERS_ROOT="${MCC_SERVERS:-$REPO_ROOT/MinecraftOfficial/downloads}"
SERVER_DIR="$SERVERS_ROOT/$SERVER_DIR_NAME"
PROPS_FILE="$SERVER_DIR/server.properties"
LATEST_LOG="$SERVER_DIR/logs/latest.log"
if [[ -f "$PROPS_FILE" ]]; then
PORT_LINE="$(grep -E '^server-port=' "$PROPS_FILE" | tail -n 1 || true)"
if [[ -n "$PORT_LINE" ]]; then
PORT="${PORT_LINE#server-port=}"
if [[ "$PORT" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$PORT"
exit 0
fi
fi
fi
if [[ -f "$LATEST_LOG" ]]; then
PORT="$(sed -n 's/.*Starting Minecraft server on .*:\([0-9][0-9]*\).*/\1/p' "$LATEST_LOG" | tail -n 1)"
if [[ -n "$PORT" ]]; then
printf '%s\n' "$PORT"
exit 0
fi
fi
printf '25565\n'

View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=tools/mcc-env.sh
source "$REPO_ROOT/tools/mcc-env.sh"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
usage() {
cat <<'EOF'
Usage: preflight_test_env.sh [server-dir...]
Checks the local MCC test environment and resolves common Java path issues.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
ensure_java_in_path
command -v tmux >/dev/null 2>&1 || { echo "tmux was not found on PATH." >&2; exit 1; }
command -v dotnet >/dev/null 2>&1 || { echo "dotnet was not found on PATH." >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo "python3 was not found on PATH." >&2; exit 1; }
if [[ ! -d "$MCC_SERVERS" ]]; then
echo "Server root not found: $MCC_SERVERS" >&2
exit 1
fi
for server_dir in "$@"; do
[[ -z "$server_dir" ]] && continue
if [[ ! -d "$MCC_SERVERS/$server_dir" ]]; then
echo "Server directory not found: $MCC_SERVERS/$server_dir" >&2
exit 1
fi
remove_stale_stdin_pipe "$server_dir"
done
printf 'MCC_REPO=%s\n' "$MCC_REPO"
printf 'MCC_SERVERS=%s\n' "$MCC_SERVERS"
printf 'JAVA=%s\n' "$(command -v java)"
printf 'TMUX=%s\n' "$(command -v tmux)"
printf 'DOTNET=%s\n' "$(command -v dotnet)"

View file

@ -0,0 +1,106 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
usage() {
cat <<'EOF' >&2
Usage:
prepare_offline_mcc_config.sh <output-ini> <mc-version> [login]
prepare_offline_mcc_config.sh <template-ini> <output-ini> <mc-version> [login]
EOF
}
if [[ $# -lt 2 || $# -gt 4 ]]; then
usage
exit 1
fi
TEMPLATE_INI=""
OUTPUT_INI=""
MC_VERSION=""
LOGIN_NAME=""
if [[ $# -ge 3 && -f "$1" ]]; then
TEMPLATE_INI="$1"
OUTPUT_INI="$2"
MC_VERSION="$3"
LOGIN_NAME="${4:-MCCBot}"
else
OUTPUT_INI="$1"
MC_VERSION="$2"
LOGIN_NAME="${3:-MCCBot}"
fi
ACCOUNT_TYPE="${MCC_TEST_ACCOUNT_TYPE:-mojang}"
PASSWORD_VALUE="${MCC_TEST_PASSWORD-}"
if [[ "$ACCOUNT_TYPE" != "mojang" && "$ACCOUNT_TYPE" != "microsoft" && "$ACCOUNT_TYPE" != "yggdrasil" ]]; then
echo "Unsupported MCC_TEST_ACCOUNT_TYPE: $ACCOUNT_TYPE" >&2
exit 1
fi
if [[ -z "${MCC_TEST_PASSWORD+x}" ]]; then
if [[ "$ACCOUNT_TYPE" == "mojang" ]]; then
PASSWORD_VALUE="-"
else
PASSWORD_VALUE=""
fi
fi
generate_template_ini() {
local template_root
template_root="$(mktemp -d "${TMPDIR:-/tmp}/mcc-config-template.XXXXXX")"
if [[ ! -f "$REPO_ROOT/MinecraftClient/bin/Release/net10.0/MinecraftClient.dll" ]]; then
dotnet build "$REPO_ROOT/MinecraftClient.sln" -c Release -v quiet --nologo >/dev/null
fi
(
cd "$template_root"
dotnet run --project "$REPO_ROOT/MinecraftClient" -c Release --no-build -- --help >/dev/null 2>&1
)
if [[ ! -f "$template_root/MinecraftClient.ini" ]]; then
echo "Failed to generate a temporary MCC config template." >&2
exit 1
fi
TEMPLATE_INI="$template_root/MinecraftClient.ini"
}
if [[ -z "$TEMPLATE_INI" ]]; then
generate_template_ini
fi
mkdir -p "$(dirname "$OUTPUT_INI")"
cp "$TEMPLATE_INI" "$OUTPUT_INI"
sed_in_place \
-e "s#^Account = .*#Account = { Login = \"$LOGIN_NAME\", Password = \"$PASSWORD_VALUE\" }#" \
-e "s#^AccountType = .*#AccountType = \"$ACCOUNT_TYPE\"#" \
-e "s#^MinecraftVersion = \"[^\"]*\"\\(.*\\)\$#MinecraftVersion = \"$MC_VERSION\"\\1#" \
-e 's#^TerrainAndMovements = false#TerrainAndMovements = true#' \
-e 's#^InventoryHandling = false#InventoryHandling = true#' \
-e 's#^EntityHandling = false#EntityHandling = true#' \
-e 's#^AutoRespawn = false#AutoRespawn = true#' \
"$OUTPUT_INI"
disable_noisy_bots_in_ini "$OUTPUT_INI"
grep -Fq "AccountType = \"$ACCOUNT_TYPE\"" "$OUTPUT_INI" || {
echo "Failed to enforce account type $ACCOUNT_TYPE in $OUTPUT_INI" >&2
exit 1
}
if [[ "$ACCOUNT_TYPE" == "mojang" ]]; then
grep -Eq '^Account = \{ Login = ".*", Password = "-" \}' "$OUTPUT_INI" || {
echo "Failed to enforce offline account in $OUTPUT_INI" >&2
exit 1
}
fi
printf '%s\n' "$OUTPUT_INI"

View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=tools/mcc-env.sh
source "$REPO_ROOT/tools/mcc-env.sh"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
usage() {
cat <<'EOF'
Usage: reset_shared_test_state.sh [--all | <server-dir>...]
Kills shared server tmux test sessions and removes stale server stdin pipes.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
kill_named_session() {
local session_name="$1"
tmux kill-session -t "$session_name" 2>/dev/null || true
}
if [[ $# -eq 0 || "${1:-}" == "--all" ]]; then
while IFS= read -r session_name; do
[[ -z "$session_name" ]] && continue
kill_named_session "$session_name"
done < <(tmux list-sessions 2>/dev/null | awk -F: '/^mc-/{print $1}' || true)
while IFS= read -r pipe_path; do
[[ -z "$pipe_path" ]] && continue
rm -f "$pipe_path"
done < <(find "$MCC_SERVERS" -maxdepth 2 -name 'stdin.pipe' 2>/dev/null || true)
else
for version in "$@"; do
kill_named_session "$(server_session_name "$version")"
remove_stale_stdin_pipe "$version"
done
fi

View file

@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=tools/mcc-env.sh
source "$REPO_ROOT/tools/mcc-env.sh"
RUN_ROOT="${TMPDIR:-/tmp}/mcc-achievements/matrix"
RUN_ID="$(date +%Y%m%d-%H%M%S)"
MATRIX_DIR="$RUN_ROOT/$RUN_ID"
RESULTS_TSV="$MATRIX_DIR/results.tsv"
BUILD_LOG="$MATRIX_DIR/build.log"
REPORT_MD="$MATRIX_DIR/report.md"
PRECHECK_TXT="$MATRIX_DIR/preflight.txt"
mkdir -p "$MATRIX_DIR"
write_row() {
local fields=("$@")
while (( ${#fields[@]} < 14 )); do
fields+=("")
done
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"${fields[0]}" "${fields[1]}" "${fields[2]}" "${fields[3]}" "${fields[4]}" "${fields[5]}" "${fields[6]}" \
"${fields[7]}" "${fields[8]}" "${fields[9]}" "${fields[10]}" "${fields[11]}" "${fields[12]}" \
"${fields[13]}" >> "$RESULTS_TSV"
}
resolve_server_dir() {
local version="$1"
local candidate
for candidate in "$version" "$version-Vanilla"; do
if [[ -d "$MCC_SERVERS/$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
run_version() {
local version="$1"
local profile="$2"
local family="$3"
local server_dir="$4"
local summary_env
if bash "$SCRIPT_DIR/run_achievements_test.sh" --no-build "$server_dir" "$version" "$profile"; then
:
fi
summary_env="${TMPDIR:-/tmp}/mcc-achievements/$server_dir/latest/summary.env"
if [[ ! -f "$summary_env" ]]; then
write_row "$version" "$server_dir" "unknown" "$family" "❌" "❌" "❌" "❌" "❌ Fail" \
"Summary file was not produced." "" "" ""
return
fi
# shellcheck disable=SC1090
source "$summary_env"
if [[ -n "${MCC_LOG:-}" && ! -f "$MCC_LOG" ]]; then
NOTE="Harness failure: MCC log was not produced."
VERDICT="❌ Fail"
fi
if [[ -n "${COMMAND_LOG:-}" && ! -f "$COMMAND_LOG" ]]; then
NOTE="Harness failure: command transcript was not produced."
VERDICT="❌ Fail"
fi
write_row "$VERSION" "$SERVER_DIR" "$PORT" "$FAMILY" "$INITIAL_STATUS" "$GRANT_STATUS" "$REVOKE_STATUS" \
"$API_STATUS" "$VERDICT" "$NOTE" "$RUN_DIR" "$MCC_LOG" "$COPIED_SERVER_LOG" "$COMMAND_LOG"
}
{
printf 'MCC_SERVERS=%s\n' "$MCC_SERVERS"
printf 'RUN_DIR=%s\n' "$MATRIX_DIR"
printf 'DATE=%s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
} > "$PRECHECK_TXT"
printf 'Version\tServerDir\tPort\tFamily\tInitial\tGrant\tRevoke\tAPI\tVerdict\tNote\tRunDir\tMccLog\tServerLog\tCommandLog\n' > "$RESULTS_TSV"
JAVA_OK="yes"
TMUX_OK="yes"
DOTNET_OK="yes"
BUILD_OK="yes"
if ! command -v dotnet >/dev/null 2>&1; then
DOTNET_OK="no"
fi
if ! command -v java >/dev/null 2>&1 || ! java -version >/dev/null 2>&1; then
JAVA_OK="no"
fi
if ! command -v tmux >/dev/null 2>&1; then
TMUX_OK="no"
fi
if [[ "$DOTNET_OK" == "yes" ]]; then
bash "$SCRIPT_DIR/preflight_test_env.sh" >/dev/null 2>&1 || true
if ! dotnet build "$REPO_ROOT/MinecraftClient.sln" -c Release > "$BUILD_LOG" 2>&1; then
BUILD_OK="no"
fi
else
: > "$BUILD_LOG"
fi
{
printf 'MCC_SERVERS=%s\n' "$MCC_SERVERS"
printf 'RUN_DIR=%s\n' "$MATRIX_DIR"
printf 'DATE=%s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
printf 'dotnet=%s\n' "$DOTNET_OK"
printf 'java=%s\n' "$JAVA_OK"
printf 'tmux=%s\n' "$TMUX_OK"
printf 'build=%s\n' "$BUILD_OK"
} > "$PRECHECK_TXT"
while IFS='|' read -r version profile family; do
[[ -z "$version" ]] && continue
if [[ "$DOTNET_OK" != "yes" ]]; then
write_row "$version" "" "" "$family" "❌" "❌" "❌" "❌" "❌ Fail" \
"dotnet is not available on PATH."
continue
fi
if [[ "$BUILD_OK" != "yes" ]]; then
write_row "$version" "" "" "$family" "❌" "❌" "❌" "❌" "❌ Fail" \
"dotnet build failed. See $BUILD_LOG."
continue
fi
if [[ "$JAVA_OK" != "yes" || "$TMUX_OK" != "yes" ]]; then
write_row "$version" "" "" "$family" "❌" "❌" "❌" "❌" "❌ Fail" \
"java or tmux is not available, so live server execution was blocked."
continue
fi
if ! server_dir="$(resolve_server_dir "$version")"; then
write_row "$version" "" "" "$family" "❌" "❌" "❌" "❌" "⚠️ Partial" \
"Server directory for $version was not found under $MCC_SERVERS."
continue
fi
run_version "$version" "$profile" "$family" "$server_dir"
done <<'EOF'
1.8|legacy|Legacy 🧱
1.11.2|legacy|Legacy 🧱
1.12.2|modern|First advancements 🌱
1.19.4|modern|Stable modern ✅
1.20|modern|Telemetry edge 1 ⚠️
1.20.2|modern|Telemetry edge 2 ⚠️
1.20.4|modern|End of 1.20.x ⚠️
1.20.6|modern|Post-1.20.6 🔧
1.21.2|modern|1.21.2 family 🔧
1.21.11|modern|showAdvancements 🆕
26.1|modern|Latest supported 🚀
EOF
bash "$SCRIPT_DIR/summarize_achievements_matrix.sh" "$MATRIX_DIR" > "$REPORT_MD"
printf '%s\n' "$MATRIX_DIR"

View file

@ -0,0 +1,399 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=tools/mcc-env.sh
source "$REPO_ROOT/tools/mcc-env.sh"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
usage() {
cat <<'EOF'
Usage: run_achievements_test.sh [--no-build] <server-dir> <mc-version> <legacy|modern>
Examples:
.skills/mcc-integration-testing/scripts/run_achievements_test.sh --no-build 1.8 1.8 legacy
.skills/mcc-integration-testing/scripts/run_achievements_test.sh --no-build 1.21.11-Vanilla 1.21.11 modern
EOF
}
DO_BUILD=true
while [[ $# -gt 0 ]]; do
case "$1" in
--no-build) DO_BUILD=false; shift ;;
--build) DO_BUILD=true; shift ;;
-h|--help) usage; exit 0 ;;
*) break ;;
esac
done
if [[ $# -ne 3 ]]; then
usage >&2
exit 1
fi
SERVER_DIR="$1"
MC_VERSION="$2"
PROFILE="$3"
SESSION_NAME="achievements-${SERVER_DIR//[^a-zA-Z0-9]/_}-${PROFILE}"
TEST_USERNAME="$(_mcc_resolve_username "$SESSION_NAME")"
if [[ "$PROFILE" != "legacy" && "$PROFILE" != "modern" ]]; then
echo "Unsupported profile: $PROFILE" >&2
exit 1
fi
RUN_ROOT="${TMPDIR:-/tmp}/mcc-achievements"
RUN_ID="$(date +%Y%m%d-%H%M%S)"
RUN_DIR="$RUN_ROOT/$SERVER_DIR/$RUN_ID"
LATEST_LINK="$RUN_ROOT/$SERVER_DIR/latest"
MCC_LOG="$RUN_DIR/mcc.log"
BUILD_LOG="$RUN_DIR/build.log"
SERVER_TMUX_LOG="$RUN_DIR/server-tmux.log"
SERVER_FILE_LOG="$RUN_DIR/server-latest.log"
COMMAND_LOG="$RUN_DIR/commands.log"
SUMMARY_ENV="$RUN_DIR/summary.env"
PROBE_SCRIPT="$RUN_DIR/achievement_probe.cs"
CFG="$RUN_DIR/MinecraftClient.$MC_VERSION.ini"
INPUT_FILE="$(_mcc_session_input_file "$SESSION_NAME")"
SERVER_LOG_FILE="$MCC_SERVERS/$SERVER_DIR/logs/latest.log"
TARGET_ID="minecraft:story/root"
TARGET_COMMAND_GRANT="advancement grant $TEST_USERNAME only minecraft:story/root"
TARGET_COMMAND_REVOKE="advancement revoke $TEST_USERNAME only minecraft:story/root"
TARGET_TYPE="Modern 🌱"
PORT="unknown"
MCC_PID=""
INITIAL_STATUS="❌"
GRANT_STATUS="❌"
REVOKE_STATUS="❌"
API_STATUS="❌"
VERDICT="❌ Fail"
NOTE="Run did not complete."
EXECUTED="yes"
if [[ "$PROFILE" == "legacy" ]]; then
TARGET_ID="achievement.openInventory"
TARGET_COMMAND_GRANT="achievement give achievement.openInventory $TEST_USERNAME"
TARGET_COMMAND_REVOKE="achievement take achievement.openInventory $TEST_USERNAME"
TARGET_TYPE="Legacy 🧱"
fi
mkdir -p "$RUN_DIR"
write_summary() {
{
printf 'VERSION=%q\n' "$MC_VERSION"
printf 'SERVER_DIR=%q\n' "$SERVER_DIR"
printf 'PROFILE=%q\n' "$PROFILE"
printf 'FAMILY=%q\n' "$TARGET_TYPE"
printf 'PORT=%q\n' "$PORT"
printf 'RUN_DIR=%q\n' "$RUN_DIR"
printf 'MCC_LOG=%q\n' "$MCC_LOG"
printf 'SERVER_LOG=%q\n' "$RUN_DIR/server-latest.log"
printf 'SERVER_FILE_LOG=%q\n' "$SERVER_LOG_FILE"
printf 'SERVER_TMUX_LOG=%q\n' "$SERVER_TMUX_LOG"
printf 'COPIED_SERVER_LOG=%q\n' "$RUN_DIR/server-latest.log"
printf 'COMMAND_LOG=%q\n' "$COMMAND_LOG"
printf 'SUMMARY_ENV=%q\n' "$SUMMARY_ENV"
printf 'TARGET_ID=%q\n' "$TARGET_ID"
printf 'INITIAL_STATUS=%q\n' "$INITIAL_STATUS"
printf 'GRANT_STATUS=%q\n' "$GRANT_STATUS"
printf 'REVOKE_STATUS=%q\n' "$REVOKE_STATUS"
printf 'API_STATUS=%q\n' "$API_STATUS"
printf 'VERDICT=%q\n' "$VERDICT"
printf 'NOTE=%q\n' "$NOTE"
printf 'EXECUTED=%q\n' "$EXECUTED"
} > "$SUMMARY_ENV"
}
capture_server_logs() {
mc-log "$SERVER_DIR" 400 > "$SERVER_TMUX_LOG" 2>/dev/null || true
if [[ -f "$SERVER_LOG_FILE" ]]; then
cp "$SERVER_LOG_FILE" "$RUN_DIR/server-latest.log" 2>/dev/null || true
fi
}
cleanup() {
capture_server_logs
if [[ -n "${MCC_PID:-}" ]] && kill -0 "$MCC_PID" 2>/dev/null; then
echo "quit" >> "$INPUT_FILE" 2>/dev/null || true
sleep 2
kill "$MCC_PID" 2>/dev/null || true
wait "$MCC_PID" 2>/dev/null || true
fi
mc-stop "$SERVER_DIR" --confirm >/dev/null 2>&1 || true
wait_for_server_stop "$SERVER_DIR" 20 >/dev/null 2>&1 || true
ln -sfn "$RUN_DIR" "$LATEST_LINK"
write_summary
}
trap cleanup EXIT
log_step() {
printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$1" | tee -a "$COMMAND_LOG"
}
fail() {
NOTE="$1"
VERDICT="❌ Fail"
exit 1
}
wait_for_file_pattern() {
local file="$1"
local pattern="$2"
local description="$3"
local timeout="${4:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if [[ -f "$file" ]] && grep -Fq "$pattern" "$file"; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for: $description" >&2
return 1
}
write_probe_script() {
cat > "$PROBE_SCRIPT" <<EOF
//MCCScript 1.0
MCC.LoadBot(new AchievementProbeBot());
//MCCScript Extensions
public class AchievementProbeBot : ChatBot
{
private const string TargetId = "$TARGET_ID";
public override void Initialize()
{
LogToConsole("[ACH_TEST] probe initialized");
DumpState("initialize");
}
public override void AfterGameJoined()
{
LogToConsole("[ACH_TEST] after join");
DumpState("after_join");
}
public override void OnAchievementUpdate(IReadOnlyList<Achievement> updated, IReadOnlyList<string> removedIds, bool reset)
{
LogToConsole($"[ACH_TEST] event reset={reset} updated={updated.Count} removed={removedIds.Count}");
DumpState("event");
}
private void DumpState(string origin)
{
Achievement[] all = GetAchievements();
Achievement[] unlocked = GetUnlockedAchievements();
Achievement[] locked = GetLockedAchievements();
Achievement? target = null;
foreach (Achievement entry in all)
{
if (entry.Id == TargetId)
{
target = entry;
break;
}
}
string titleState = "missing";
string completionState = "missing";
if (target is not null)
{
titleState = target.Title is null ? "null" : "present";
completionState = target.IsCompleted ? "done" : "todo";
}
LogToConsole($"[ACH_TEST] snapshot origin={origin} all={all.Length} unlocked={unlocked.Length} locked={locked.Length}");
LogToConsole($"[ACH_TEST] target_state origin={origin} id={TargetId} title={titleState} completed={completionState}");
}
}
EOF
}
run_server_command() {
local cmd="$1"
local attempt
log_step "SERVER> $cmd"
for attempt in 1 2 3 4 5; do
if mc-rcon "$cmd" >/dev/null 2>&1; then
sleep 1
return 0
fi
sleep 1
done
fail "Server command failed: $cmd"
}
run_mcc_command() {
local name="$1"
local cmd="$2"
local delay="${3:-2}"
local start_line=0
local end_line=0
if [[ -f "$MCC_LOG" ]]; then
start_line="$(wc -l < "$MCC_LOG")"
fi
log_step "MCC> $cmd"
echo "$cmd" >> "$INPUT_FILE"
sleep "$delay"
if [[ -f "$MCC_LOG" ]]; then
end_line="$(wc -l < "$MCC_LOG")"
fi
if (( end_line > start_line )); then
sed -n "$((start_line + 1)),$((end_line))p" "$MCC_LOG" > "$RUN_DIR/$name.mcc.log"
else
: > "$RUN_DIR/$name.mcc.log"
fi
}
assert_pattern() {
local file="$1"
local pattern="$2"
local description="$3"
grep -Fq "$pattern" "$file" || fail "$description"
}
if $DO_BUILD; then
log_step "BUILD> dotnet build MinecraftClient.sln -c Release"
mcc-build > "$BUILD_LOG" 2>&1 || fail "dotnet build failed."
else
: > "$BUILD_LOG"
fi
bash "$SCRIPT_DIR/preflight_test_env.sh" "$SERVER_DIR" >/dev/null || fail "Test environment preflight failed."
bash "$SCRIPT_DIR/reset_shared_test_state.sh" "$SERVER_DIR" >/dev/null || fail "Failed to reset shared test state."
if [[ ! -d "$MCC_SERVERS/$SERVER_DIR" ]]; then
fail "Server directory not found: $MCC_SERVERS/$SERVER_DIR"
fi
bash "$SCRIPT_DIR/prepare_offline_mcc_config.sh" "$CFG" "$MC_VERSION" "$TEST_USERNAME" >/dev/null || fail "Failed to prepare temporary MCC config."
PORT="$(bash "$SCRIPT_DIR/get_server_port.sh" "$SERVER_DIR")"
"$SCRIPT_DIR/ensure_offline_server.sh" "$SERVER_DIR"
write_probe_script
if [[ "$PROFILE" == "legacy" && -f "$MCC_SERVERS/$SERVER_DIR/server.properties" ]]; then
sed_in_place 's/^use-native-transport=.*/use-native-transport=false/' "$MCC_SERVERS/$SERVER_DIR/server.properties"
fi
mkdir -p "$(dirname "$INPUT_FILE")"
: > "$INPUT_FILE"
rm -f "$MCC_LOG"
log_step "Starting server $SERVER_DIR on port $PORT"
mc-start "$SERVER_DIR" >/dev/null
wait_for_server_ready "$SERVER_DIR" || fail "Server did not become ready."
log_step "Starting MCC for $MC_VERSION"
(
cd "$REPO_ROOT"
MCC_FILE_INPUT=1 MCC_INPUT_FILE="$INPUT_FILE" dotnet run --project MinecraftClient -c Release --no-build -- \
"$CFG" \
"$TEST_USERNAME" \
- \
"localhost:$PORT" \
"--accounttype=mojang" \
"--minecraftversion=$MC_VERSION" \
"--terrainandmovements=true" \
"--inventoryhandling=true" \
"--entityhandling=true" \
"--autorespawn=true" \
"--debugmessages=true" \
> "$MCC_LOG" 2>&1
) &
MCC_PID=$!
wait_for_file_pattern "$MCC_LOG" "Server was successfully joined." "MCC join success" 90 || fail "MCC failed to join."
wait_for_file_pattern "$SERVER_LOG_FILE" "$TEST_USERNAME joined the game" "server join entry" 30 || fail "Server never logged the join."
run_server_command "op $TEST_USERNAME"
run_server_command "gamerule sendCommandFeedback true"
if [[ "$PROFILE" == "modern" ]]; then
run_server_command "gamerule logAdminCommands true"
fi
run_server_command "time set day"
run_server_command "weather clear"
run_mcc_command "load_probe" "script $PROBE_SCRIPT" 3
wait_for_file_pattern "$MCC_LOG" "[ACH_TEST] probe initialized" "probe startup" 30 || fail "Probe script did not initialize."
run_mcc_command "baseline_debug" "debug state" 2
run_mcc_command "baseline_all" "achievement" 2
run_mcc_command "baseline_locked" "achievement locked" 2
run_mcc_command "baseline_unlocked" "achievement unlocked" 2
run_server_command "$TARGET_COMMAND_GRANT"
sleep 3
run_mcc_command "after_grant_all" "achievement" 2
run_mcc_command "after_grant_unlocked" "achievement unlocked" 2
run_server_command "$TARGET_COMMAND_REVOKE"
sleep 3
run_mcc_command "after_revoke_all" "achievement" 2
run_mcc_command "after_revoke_locked" "achievement locked" 2
assert_pattern "$MCC_LOG" "Achievements/Advancements:" "Achievement command header never appeared."
if ! grep -Fq "No achievements/advancements received yet." "$RUN_DIR/baseline_all.mcc.log"; then
INITIAL_STATUS="✅"
fi
if grep -Fq "$TARGET_ID" "$RUN_DIR/after_grant_unlocked.mcc.log" && grep -Fq "[DONE]" "$RUN_DIR/after_grant_unlocked.mcc.log"; then
GRANT_STATUS="✅"
fi
if [[ "$PROFILE" == "legacy" ]]; then
if grep -Fq "$TARGET_ID" "$RUN_DIR/after_revoke_locked.mcc.log" && grep -Fq "[TODO]" "$RUN_DIR/after_revoke_locked.mcc.log"; then
REVOKE_STATUS="✅"
fi
else
if grep -Fq "$TARGET_ID" "$RUN_DIR/after_revoke_locked.mcc.log" && grep -Fq "[TODO]" "$RUN_DIR/after_revoke_locked.mcc.log"; then
REVOKE_STATUS="✅"
elif [[ "$GRANT_STATUS" == "✅" ]] && ! grep -Fq "$TARGET_ID" "$RUN_DIR/after_revoke_all.mcc.log"; then
REVOKE_STATUS="✅"
fi
fi
if grep -Fq "[ACH_TEST] event" "$MCC_LOG" && grep -Fq "target_state origin=event id=$TARGET_ID title=" "$MCC_LOG"; then
API_STATUS="✅"
fi
case "$INITIAL_STATUS|$GRANT_STATUS|$REVOKE_STATUS|$API_STATUS" in
"✅|✅|✅|✅")
VERDICT="✅ Pass"
NOTE="All planned achievement checks passed."
;;
*"✅"*)
VERDICT="⚠️ Partial"
NOTE="At least one achievement phase passed, but the matrix did not fully clear."
;;
*)
VERDICT="❌ Fail"
NOTE="Achievement checks did not produce the expected evidence."
;;
esac
run_mcc_command "quit" "quit" 2
NOTE="$NOTE Artifacts saved in $RUN_DIR."

View file

@ -0,0 +1,336 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=tools/mcc-env.sh
source "$REPO_ROOT/tools/mcc-env.sh"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
VERSION="${1:-1.21.11-Vanilla}"
MC_VERSION="${VERSION%-Vanilla}"
if [[ "$MC_VERSION" == "$VERSION" ]]; then
MC_VERSION="$VERSION"
fi
RUN_ROOT="${TMPDIR:-/tmp}/mcc-integration-testing"
RUN_ID="$(date +%Y%m%d-%H%M%S)"
RUN_DIR="$RUN_ROOT/$RUN_ID"
SERVER_LOG_FILE="$MCC_SERVERS/$VERSION/logs/latest.log"
SESSION_NAME="full-spectrum-${MC_VERSION//[^a-zA-Z0-9]/_}"
TEST_USERNAME="$(_mcc_resolve_username "$SESSION_NAME")"
MCC_LOG="$(_mcc_session_log_file "$SESSION_NAME")"
PID_FILE="$(_mcc_session_pid_file "$SESSION_NAME")"
MCC_TMUX_SESSION="$(_mcc_tmux_session_name "$SESSION_NAME")"
BUILD_LOG="$RUN_DIR/build.log"
SERVER_TMUX_LOG="$RUN_DIR/server-tmux.log"
SERVER_FILE_LOG="$RUN_DIR/server-latest.log"
INPUT_FILE="$(_mcc_session_input_file "$SESSION_NAME")"
CFG="$RUN_DIR/MinecraftClient.$MC_VERSION.ini"
mkdir -p "$RUN_DIR"
cleanup() {
mcc-cmd --session "$SESSION_NAME" "quit" >/dev/null 2>&1 || true
sleep 2
mcc-kill --session "$SESSION_NAME" >/dev/null 2>&1 || true
mc-stop "$VERSION" --confirm >/dev/null 2>&1 || true
wait_for_server_stop "$VERSION" 20 >/dev/null 2>&1 || true
}
trap cleanup EXIT
prepare_config() {
bash "$SCRIPT_DIR/prepare_offline_mcc_config.sh" "$CFG" "$MC_VERSION" "$TEST_USERNAME" >/dev/null
}
wait_for_server_log_pattern() {
local pattern="$1"
local description="$2"
local timeout="${3:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if [[ -f "$SERVER_LOG_FILE" ]] && grep -Fq "$pattern" "$SERVER_LOG_FILE"; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for server log: $description" >&2
return 1
}
capture_server_logs() {
mc-log "$VERSION" 400 > "$SERVER_TMUX_LOG" 2>/dev/null || true
if [[ -f "$SERVER_LOG_FILE" ]]; then
cp "$SERVER_LOG_FILE" "$SERVER_FILE_LOG"
fi
}
wait_for_file_pattern() {
local file="$1"
local pattern="$2"
local description="$3"
local timeout="${4:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if [[ -f "$file" ]] && grep -Fq "$pattern" "$file"; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for: $description" >&2
return 1
}
fail() {
capture_server_logs
echo "FAIL: $1" >&2
echo "Run directory: $RUN_DIR" >&2
exit 1
}
assert_contains() {
local file="$1"
local pattern="$2"
local description="$3"
grep -Fq "$pattern" "$file" || fail "$description"
}
assert_not_contains() {
local file="$1"
local pattern="$2"
local description="$3"
if grep -Fq "$pattern" "$file"; then
fail "$description"
fi
}
run_server_command() {
local cmd="$1"
local attempt
echo "SERVER> $cmd"
for attempt in 1 2 3 4 5; do
if mc-rcon "$cmd" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
fail "Server command failed: $cmd"
}
run_mcc_command() {
local cmd="$1"
echo "MCC> $cmd"
mcc-cmd --session "$SESSION_NAME" "$cmd"
sleep 2
}
start_mcc_session() {
local -a mcc_args=("$CFG" "$TEST_USERNAME" "-" "localhost:$SERVER_PORT")
local mcc_args_cmd
mcc_args_cmd="$(printf '%q ' "${mcc_args[@]}")"
tmux kill-session -t "$MCC_TMUX_SESSION" 2>/dev/null || true
rm -f "$PID_FILE"
tmux new-session -d -s "$MCC_TMUX_SESSION" -x 160 -y 50 \
"cd '$REPO_ROOT' && printf '%s\n' \"\$\$\" > '$PID_FILE' && exec env MCC_FILE_INPUT=1 MCC_INPUT_FILE='$INPUT_FILE' dotnet run --project MinecraftClient -c Release --no-build -- $mcc_args_cmd > '$MCC_LOG' 2>&1"
for _ in $(seq 1 25); do
if [[ -s "$PID_FILE" ]]; then
return 0
fi
sleep 0.2
done
fail "Failed to capture MCC PID for session $SESSION_NAME"
}
bash "$SCRIPT_DIR/preflight_test_env.sh" "$VERSION" >/dev/null
bash "$SCRIPT_DIR/reset_shared_test_state.sh" "$VERSION" >/dev/null
"$SCRIPT_DIR/ensure_offline_server.sh" "$VERSION"
mcc-reset-session --session "$SESSION_NAME" >/dev/null
echo "Building MCC..."
mcc-build > "$BUILD_LOG" 2>&1 || fail "mcc-build failed"
prepare_config
SERVER_PORT="$(bash "$SCRIPT_DIR/get_server_port.sh" "$VERSION")"
if [[ -z "$SERVER_PORT" ]]; then
fail "Failed to resolve server port"
fi
mkdir -p "$(dirname "$INPUT_FILE")" "$(dirname "$MCC_LOG")"
: > "$INPUT_FILE"
rm -f "$MCC_LOG"
echo "Starting server..."
mc-start "$VERSION" >/dev/null
wait_for_server_ready "$VERSION" || fail "Server did not become ready"
echo "Starting MCC..."
start_mcc_session
wait_for_file_pattern "$MCC_LOG" "Server was successfully joined." "MCC join success" 90 || fail "MCC failed to join"
wait_for_server_log_pattern "$TEST_USERNAME joined the game" "server join entry" 30 || fail "Server never logged the join"
run_server_command "op $TEST_USERNAME"
run_server_command "gamerule sendCommandFeedback true"
run_server_command "gamerule logAdminCommands true"
run_server_command "time set day"
run_server_command "weather clear"
sleep 2
# ── Phase 1: Basic status and info commands ──
run_mcc_command "health"
run_mcc_command "list"
run_mcc_command "inventory player list"
run_mcc_command "/gamemode creative"
run_mcc_command "inventory creativegive 36 Diamond 16"
run_mcc_command "inventory player list"
run_mcc_command "entity"
run_mcc_command "/time query daytime"
run_mcc_command "smoke_test_from_mcc_full_spectrum"
# ── Phase 2: Movement and look commands ──
run_mcc_command "/tp $TEST_USERNAME 0 -60 0"
sleep 3
run_mcc_command "look up"
sleep 1
run_mcc_command "look down"
sleep 1
run_mcc_command "look east"
sleep 1
# ── Phase 3: Advanced inventory operations ──
run_mcc_command "inventory creativegive 37 IronSword 1"
run_mcc_command "inventory creativegive 38 GoldenApple 8"
run_mcc_command "inventory player list"
run_mcc_command "inventory creativeclear 38"
run_mcc_command "inventory player list"
# ── Phase 4: Block placement and interaction ──
run_server_command "execute as $TEST_USERNAME at @s run fill ~1 ~ ~1 ~3 ~2 ~3 minecraft:stone"
sleep 2
run_server_command "execute as $TEST_USERNAME at @s run setblock ~5 ~ ~5 minecraft:chest"
sleep 1
run_server_command "execute as $TEST_USERNAME at @s run setblock ~5 ~1 ~5 minecraft:furnace"
sleep 1
run_server_command "execute as $TEST_USERNAME at @s run setblock ~6 ~ ~5 minecraft:crafting_table"
sleep 1
# ── Phase 5: Entity spawning (expanded coverage) ──
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:cow ~2 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:zombie ~4 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:creeper ~6 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:skeleton ~8 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:villager ~-2 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:allay ~-4 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:armor_stand ~ ~ ~2"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:item_display ~-6 ~ ~ {item:{id:\"minecraft:diamond\",count:1}}"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:spider ~10 ~ ~"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:pig ~-8 ~ ~"
sleep 2
run_mcc_command "entity"
# ── Phase 6: Effects and environment ──
run_server_command "effect give $TEST_USERNAME minecraft:speed 30 1"
sleep 2
run_mcc_command "health"
run_server_command "effect give $TEST_USERNAME minecraft:regeneration 10 1"
sleep 2
run_mcc_command "health"
# ── Phase 7: Gamemode cycling ──
run_mcc_command "/gamemode survival"
sleep 2
run_mcc_command "health"
run_mcc_command "/gamemode creative"
sleep 2
# ── Phase 8: Dimension change (nether) ──
run_server_command "execute in minecraft:the_nether run tp $TEST_USERNAME 0 64 0"
sleep 4
run_mcc_command "health"
run_server_command "execute in minecraft:overworld run tp $TEST_USERNAME 0 -60 0"
sleep 4
# ── Phase 9: Server chat and whisper ──
run_server_command "say Hello from the server console"
sleep 2
run_server_command "msg $TEST_USERNAME This is a private whisper"
sleep 2
run_mcc_command "integration_test_chat_response"
# ── Phase 10: Particles, sounds, and explosions ──
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:happy_villager ~ ~1 ~ 0.5 0.5 0.5 0 12 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:end_rod ~ ~1 ~ 0.5 0.5 0.5 0.01 20 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:explosion ~ ~1 ~ 0 0 0 0 1 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:totem_of_undying ~ ~1 ~ 0.5 0.5 0.5 0.1 20 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:flame ~ ~1 ~ 0.2 0.2 0.2 0.02 30 force"
run_server_command "execute as $TEST_USERNAME at @s run particle minecraft:heart ~ ~2 ~ 0.3 0.3 0.3 0 5 force"
run_server_command "execute as $TEST_USERNAME at @s run playsound minecraft:entity.lightning_bolt.thunder master $TEST_USERNAME ~ ~ ~ 1 1 0"
run_server_command "execute as $TEST_USERNAME at @s run playsound minecraft:block.note_block.bell master $TEST_USERNAME ~ ~ ~ 1 1 0"
run_server_command "execute as $TEST_USERNAME at @s run playsound minecraft:entity.experience_orb.pickup master $TEST_USERNAME ~ ~ ~ 1 1 0"
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:tnt ~3 ~ ~"
sleep 2
run_server_command "execute as $TEST_USERNAME at @s run summon minecraft:tnt ~6 ~ ~"
# ── Phase 11: Kill and respawn cycle ──
run_mcc_command "/gamemode survival"
sleep 2
run_server_command "kill $TEST_USERNAME"
sleep 4
run_mcc_command "respawn"
sleep 4
run_mcc_command "health"
run_mcc_command "/gamemode creative"
sleep 2
sleep 6
capture_server_logs
# ── Assertions: MCC log ──
assert_contains "$MCC_LOG" "Server was successfully joined." "MCC never joined the server"
assert_contains "$MCC_LOG" "[FileInput] > inventory player list" "Inventory command was not executed"
assert_contains "$MCC_LOG" "[FileInput] > entity" "Entity command was not executed"
assert_contains "$MCC_LOG" "[FileInput] > /gamemode creative" "Creative mode command was not executed from MCC"
assert_contains "$MCC_LOG" "Requested Diamond x16 in slot #36" "Creative inventory give did not succeed"
assert_contains "$MCC_LOG" "smoke_test_from_mcc_full_spectrum" "Client-originated chat was not observed"
assert_contains "$MCC_LOG" "[FileInput] > look up" "Look command was not executed"
assert_contains "$MCC_LOG" "[FileInput] > /gamemode survival" "Survival mode switch was not executed"
assert_contains "$MCC_LOG" "[FileInput] > respawn" "Respawn command was not executed"
assert_contains "$MCC_LOG" "[FileInput] > health" "Health command was not executed"
assert_contains "$MCC_LOG" "integration_test_chat_response" "Chat response test message was not observed"
assert_not_contains "$MCC_LOG" "Please enable InventoryHandling" "Inventory handling is still disabled"
assert_not_contains "$MCC_LOG" "Please enable EntityHandling" "Entity handling is still disabled"
assert_not_contains "$MCC_LOG" "You must be in Creative gamemode" "Creative mode was not active when creativegive ran"
assert_not_contains "$MCC_LOG" "Failed to load settings" "MCC failed to reload its config"
assert_not_contains "$MCC_LOG" "NullReferenceException" "A NullReferenceException occurred during the test"
# ── Assertions: Server log ──
assert_contains "$SERVER_FILE_LOG" "$TEST_USERNAME joined the game" "Server never saw $TEST_USERNAME join"
assert_contains "$SERVER_FILE_LOG" "smoke_test_from_mcc_full_spectrum" "Server never received the client chat message"
assert_contains "$SERVER_FILE_LOG" "Displaying particle minecraft:happy_villager" "Particle events were not recorded on the server"
assert_contains "$SERVER_FILE_LOG" "Played sound minecraft:block.note_block.bell to $TEST_USERNAME" "Sound events were not recorded on the server"
assert_contains "$SERVER_FILE_LOG" "Summoned new Primed TNT" "TNT summon did not occur on the server"
assert_contains "$SERVER_FILE_LOG" "integration_test_chat_response" "Server never received the chat response test message"
assert_contains "$SERVER_FILE_LOG" "Hello from the server console" "Server say command was not logged"
assert_contains "$SERVER_FILE_LOG" "Killed $TEST_USERNAME" "Server kill command did not execute"
assert_not_contains "$SERVER_FILE_LOG" "Sending unknown packet 'clientbound/minecraft:disconnect'" "Server hit the disconnect packet regression during the test"
cat <<EOF
PASS
Run directory: $RUN_DIR
MCC log: $MCC_LOG
Server log: $SERVER_FILE_LOG
Build log: $BUILD_LOG
EOF

View file

@ -0,0 +1,199 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=tools/mcc-env.sh
source "$REPO_ROOT/tools/mcc-env.sh"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
VERSION="${1:-1.21.11-Vanilla}"
MC_VERSION="${VERSION%-Vanilla}"
if [[ "$MC_VERSION" == "$VERSION" ]]; then
MC_VERSION="$VERSION"
fi
SESSION_A="parallel-smoke-a-${MC_VERSION//[^a-zA-Z0-9]/_}"
SESSION_B="parallel-smoke-b-${MC_VERSION//[^a-zA-Z0-9]/_}"
USERNAME_A="SmokeA"
USERNAME_B="SmokeB"
RUN_ROOT="${TMPDIR:-/tmp}/mcc-integration-testing"
RUN_ID="$(date +%Y%m%d-%H%M%S)"
RUN_DIR="$RUN_ROOT/parallel-smoke-$RUN_ID"
BUILD_LOG="$RUN_DIR/build.log"
SERVER_TMUX_LOG="$RUN_DIR/server-tmux.log"
SERVER_FILE_LOG="$RUN_DIR/server-latest.log"
SERVER_LOG_FILE="$MCC_SERVERS/$VERSION/logs/latest.log"
LOG_A="$(_mcc_session_log_file "$SESSION_A")"
LOG_B="$(_mcc_session_log_file "$SESSION_B")"
INPUT_A="$(_mcc_session_input_file "$SESSION_A")"
INPUT_B="$(_mcc_session_input_file "$SESSION_B")"
PID_A_FILE="$(_mcc_session_pid_file "$SESSION_A")"
PID_B_FILE="$(_mcc_session_pid_file "$SESSION_B")"
mkdir -p "$RUN_DIR"
wait_for_file_pattern() {
local file="$1"
local pattern="$2"
local description="$3"
local timeout="${4:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if [[ -f "$file" ]] && grep -Fq "$pattern" "$file"; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for: $description" >&2
return 1
}
wait_for_server_log_pattern() {
local pattern="$1"
local description="$2"
local timeout="${3:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if [[ -f "$SERVER_LOG_FILE" ]] && grep -Fq "$pattern" "$SERVER_LOG_FILE"; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for server log: $description" >&2
return 1
}
capture_server_logs() {
mc-log "$VERSION" 400 > "$SERVER_TMUX_LOG" 2>/dev/null || true
if [[ -f "$SERVER_LOG_FILE" ]]; then
cp "$SERVER_LOG_FILE" "$SERVER_FILE_LOG"
fi
}
fail() {
capture_server_logs
echo "FAIL: $1" >&2
echo "Run directory: $RUN_DIR" >&2
exit 1
}
cleanup() {
mcc-cmd --session "$SESSION_A" "quit" >/dev/null 2>&1 || true
mcc-cmd --session "$SESSION_B" "quit" >/dev/null 2>&1 || true
sleep 1
mcc-kill --session "$SESSION_A" >/dev/null 2>&1 || true
mcc-kill --session "$SESSION_B" >/dev/null 2>&1 || true
mc-stop "$VERSION" --confirm >/dev/null 2>&1 || true
wait_for_server_stop "$VERSION" 20 >/dev/null 2>&1 || true
}
trap cleanup EXIT
assert_session_alive() {
local session="$1"
local pid_file="$2"
if [[ -s "$pid_file" ]]; then
local pid
pid="$(tr -cd '0-9' < "$pid_file")"
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
return 0
fi
fi
tmux has-session -t "$(_mcc_tmux_session_name "$session")" 2>/dev/null
}
assert_server_alive() {
server_running "$VERSION" || fail "Shared server session is not alive"
}
start_file_input_session() {
local session="$1"
local username="$2"
local port="$3"
bash "$REPO_ROOT/tools/mcc-debug.sh" \
--version "$VERSION" \
--port "$port" \
--file-input \
--no-build \
--session "$session" \
--username "$username" >/dev/null
}
bash "$SCRIPT_DIR/preflight_test_env.sh" "$VERSION" >/dev/null
bash "$SCRIPT_DIR/reset_shared_test_state.sh" "$VERSION" >/dev/null
"$SCRIPT_DIR/ensure_offline_server.sh" "$VERSION" >/dev/null
mcc-reset-session --session "$SESSION_A" >/dev/null
mcc-reset-session --session "$SESSION_B" >/dev/null
echo "Building MCC..."
mcc-build > "$BUILD_LOG" 2>&1 || fail "mcc-build failed"
echo "Starting shared server..."
mc-start "$VERSION" >/dev/null
wait_for_server_ready "$VERSION" || fail "Server did not become ready"
SERVER_PORT="$(bash "$SCRIPT_DIR/get_server_port.sh" "$VERSION")"
if [[ -z "$SERVER_PORT" ]]; then
fail "Failed to resolve server port"
fi
echo "Starting MCC session A..."
start_file_input_session "$SESSION_A" "$USERNAME_A" "$SERVER_PORT"
echo "Starting MCC session B..."
start_file_input_session "$SESSION_B" "$USERNAME_B" "$SERVER_PORT"
wait_for_file_pattern "$LOG_A" "Server was successfully joined." "session A join success" 90 || fail "Session A failed to join"
wait_for_file_pattern "$LOG_B" "Server was successfully joined." "session B join success" 90 || fail "Session B failed to join"
wait_for_server_log_pattern "$USERNAME_A joined the game" "server join for session A" 30 || fail "Server never logged $USERNAME_A join"
wait_for_server_log_pattern "$USERNAME_B joined the game" "server join for session B" 30 || fail "Server never logged $USERNAME_B join"
echo "Sending debug state to both sessions..."
mcc-cmd --session "$SESSION_A" "debug state"
mcc-cmd --session "$SESSION_B" "debug state"
sleep 2
wait_for_file_pattern "$LOG_A" "[FileInput] > debug state" "session A debug state command" 20 || fail "Session A did not consume debug state"
wait_for_file_pattern "$LOG_B" "[FileInput] > debug state" "session B debug state command" 20 || fail "Session B did not consume debug state"
echo "Killing session A..."
mcc-kill --session "$SESSION_A" >/dev/null 2>&1 || true
sleep 2
assert_session_alive "$SESSION_B" "$PID_B_FILE" || fail "Session B is not alive after killing session A"
assert_server_alive
echo "Verifying session B still responds..."
mcc-cmd --session "$SESSION_B" "health"
wait_for_file_pattern "$LOG_B" "[FileInput] > health" "session B health command after session A kill" 20 || fail "Session B stopped responding after session A kill"
if [[ -s "$PID_A_FILE" ]]; then
pid_a="$(tr -cd '0-9' < "$PID_A_FILE")"
if [[ -n "$pid_a" ]] && kill -0 "$pid_a" 2>/dev/null; then
fail "Session A is still alive after mcc-kill"
fi
fi
capture_server_logs
cat <<EOF
PASS
Run directory: $RUN_DIR
Server version: $VERSION
Server port: $SERVER_PORT
Session A: $SESSION_A ($USERNAME_A)
Session A input: $INPUT_A
Session A log: $LOG_A
Session B: $SESSION_B ($USERNAME_B)
Session B input: $INPUT_B
Session B log: $LOG_B
Server log: $SERVER_FILE_LOG
Build log: $BUILD_LOG
EOF

View file

@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: summarize_achievements_matrix.sh <matrix-run-dir>" >&2
exit 1
fi
MATRIX_DIR="$1"
RESULTS_TSV="$MATRIX_DIR/results.tsv"
PRECHECK_TXT="$MATRIX_DIR/preflight.txt"
BUILD_LOG="$MATRIX_DIR/build.log"
if [[ ! -f "$RESULTS_TSV" ]]; then
echo "Missing results file: $RESULTS_TSV" >&2
exit 1
fi
echo "# Achievements Matrix Report"
echo
echo "## Executed"
echo
if [[ -f "$PRECHECK_TXT" ]]; then
echo '```text'
cat "$PRECHECK_TXT"
echo '```'
fi
echo
echo "- Matrix artifacts: \`$MATRIX_DIR\`"
echo "- Results TSV: \`$RESULTS_TSV\`"
echo "- Build log: \`$BUILD_LOG\`"
echo "- Execution mode: sequential"
echo "- Auth mode: offline"
echo
echo "## Observed"
echo
echo "| Version | Port | Family | Initial snapshot | Grant | Revoke | API callback | Verdict |"
echo "|---|---:|---|---|---|---|---|---|"
awk -F '\t' 'NR > 1 {
printf("| `%s` | `%s` | %s | %s | %s | %s | %s | %s |\n",
$1, $3, $4, $5, $6, $7, $8, $9);
}' "$RESULTS_TSV"
echo
echo "## Artifact Links"
echo
awk -F '\t' 'NR > 1 {
printf("- `%s`: run=`%s`, mcc=`%s`, server=`%s`, commands=`%s`\n", $1, $11, $12, $13, $14);
printf(" note: %s\n", $10);
}' "$RESULTS_TSV"
echo
echo "## Inferred"
echo
echo "- Only rows with real MCC and server-log artifacts count as executed proof."
echo "- Rows blocked by missing Java, tmux, or server directories are environment-limited, not product pass results."
echo "- Rows with missing MCC or command-log artifacts should be treated as harness failures until rerun confirms a product issue."

View file

@ -0,0 +1,25 @@
#!/usr/bin/env zsh
set -euo pipefail
RUN_ROOT="${TMPDIR:-/tmp}/mcc-integration-testing"
RUN_DIR="${1:-$(find "$RUN_ROOT" -mindepth 1 -maxdepth 1 -type d | sort | tail -n 1)}"
if [[ -z "${RUN_DIR:-}" ]] || [[ ! -d "$RUN_DIR" ]]; then
echo "Run directory not found" >&2
exit 1
fi
MCC_LOG="$RUN_DIR/mcc.log"
SERVER_LOG="$RUN_DIR/server-latest.log"
BUILD_LOG="$RUN_DIR/build.log"
echo "Run directory: $RUN_DIR"
echo
echo "Build result:"
grep -E "Warning\(s\)|Error\(s\)|Time Elapsed" "$BUILD_LOG" || true
echo
echo "MCC highlights:"
grep -E "Server was successfully joined|FileInput|smoke_test_from_mcc_full_spectrum|There are [0-9]+ of a max|health|Creative" "$MCC_LOG" || true
echo
echo "Server highlights:"
grep -E "joined the game|Made .* a server operator|game mode|smoke_test_from_mcc_full_spectrum|summon|particle|playsound|tnt" "$SERVER_LOG" || true

View file

@ -0,0 +1,406 @@
---
name: mcc-prompt-engineer
description: >
Manually triggered skill for the Minecraft Console Client (MCC) project
(https://github.com/MCCTeam/Minecraft-Console-Client). Invoke this skill
when the user wants to create, design, or generate a high-quality prompt for
addressing any MCC-related development request -- bug fixes, new features,
refactors, protocol work, authentication, bot scripting, or architecture
decisions. The skill interviews the user, explores the MCC codebase via
sub-agents, identifies relevant project skills, and synthesises everything
into a state-of-the-art, self-contained prompt that includes an embedded
reasoning framework, plan-mode directives, skill references, and targeted
sub-agent instructions. Do NOT trigger automatically; wait for the user to
explicitly invoke it (e.g. "generate a prompt for...", "build me a prompt",
"/mcc-prompt-engineer", or "use the MCC prompt skill").
compatibility: "Claude Code, Cursor, Codex, GitHub Copilot, Windsurf, and any AI coding agent. Optional tools: AskUserQuestion, Task, WebSearch, plan."
---
# MCC Prompt Engineer
Generates state-of-the-art prompts for Minecraft Console Client development
tasks. Combines live codebase knowledge (via sub-agents and AGENTS.md),
structured prompt engineering patterns, an embedded ULTRATHINK reasoning
framework, and the MCC project's skill ecosystem so the produced prompt is
immediately ready to use in any AI coding agent.
---
## Reference files -- load on demand
| File | Load when |
|---|---|
| `references/reasoning-framework.md` | Embedding the ULTRATHINK protocol into the generated prompt |
| `references/prompt-patterns.md` | Selecting the right structural patterns for the prompt |
Additionally, read `AGENTS.md` at the repository root early in the process.
It contains the authoritative codebase map -- module responsibilities, key
file paths, architecture overview, version support table, and engineering
DO/DON'T guidance -- and replaces the need for broad exploratory file reads.
---
## Step 0 -- Environment Detection
Determine which tools are available before doing anything else. This gates how
you ask questions and spawn sub-agents.
```
Claude Code -> AskUserQuestion and Task tools; plan mode via "plan" tool
or /plan command.
Cursor / Codex -> No AskUserQuestion; ask clarifying questions inline as a
numbered list; sub-agents via parallel tool calls where
supported, otherwise inline.
GitHub Copilot -> Similar to Cursor; use runSubagent where available.
Other agents -> Fall back to inline questions and sequential exploration.
```
Record your environment determination internally before continuing.
---
## Step 1 -- Parse the Request
Extract everything the user has stated. Do not invent requirements or make
assumptions yet. Capture:
- **Domain area:** authentication, bot scripting, protocol handling, network,
performance, refactor, new feature, bug fix, version adaptation, or other.
- **Stated goal:** what the user wants to achieve.
- **Known constraints:** language version (C# 14 / .NET 10), compatibility
requirements, scope limits (additive-only, etc.).
- **References provided:** URLs, file paths, issue numbers, error messages.
- **Ambiguity level:** High (proceed) / Medium (note gaps) / Low (clarify
before continuing).
---
## Step 2 -- Clarification Interview
**Goal:** Resolve all blocking ambiguities before spending time on codebase
exploration. Unblocking questions first saves sub-agent round-trips.
### If in Claude Code
Use the `AskUserQuestion` tool. Ask all questions in a single call -- do not
drip-feed questions turn by turn.
### In any other environment
Print a numbered list of questions. Wait for answers before proceeding.
### Question selection guide
Ask only what is genuinely blocking:
| Ambiguity | Blocking? | Example question |
|---|---|---|
| Scope of change (additive vs rewrite) | Yes | "Should this be additive, or can it replace existing code?" |
| Target .NET / C# version | Yes if non-obvious | "Which .NET version -- 8, 10, or latest?" |
| Auth flow variant | Yes for auth tasks | "Device-code flow, interactive browser, or both?" |
| Performance constraints | Usually no | Skip unless the user mentioned perf |
| Test coverage expectation | Sometimes | "Do you want unit tests, or integration guidance only?" |
**Always ask:**
1. "Is there a specific file, class, or method you already know is the right
starting point?"
2. "Are there any hard constraints -- things the solution must NOT do or touch?"
Offer a best-guess assumption alongside each question so the user can confirm
or correct rather than answer from scratch.
---
## Step 3 -- Codebase Exploration
Start by reading `AGENTS.md` at the repository root. It provides the
authoritative module map, architecture overview, version support table, and
engineering DO/DON'T guidance. Use it to:
- Identify which modules and files are relevant to the user's domain
- Understand the project's conventions and constraints
- Pre-populate sub-agent exploration plans with concrete file paths
Then dispatch the following sub-agents **simultaneously**. Each must return a
concise written summary only -- raw file contents and grep output waste context
and degrade reasoning quality downstream (context rot).
### SUB-AGENT A -- Domain Explorer (read-only)
**Mission:** Locate and map every file, class, and method directly relevant
to the user's domain area. Scope your search using the module map from
AGENTS.md rather than exploring the entire repository.
**Scoped exploration plan (fill in before dispatching):**
```
Files / directories to read:
[derived from AGENTS.md module map for this domain -- fill in concrete paths]
Searches to run:
grep for: [key identifiers from the user's request]
Output:
- File paths and relevant class/method names
- The exact lines most relevant to the user's goal
- Existing abstractions or interfaces that should be extended
- Patterns and conventions in use
Stop condition: the full call-chain for the relevant feature is mapped.
```
### SUB-AGENT B -- Dependency & Integration Scout (read-only)
**Mission:** Identify everything that calls into or depends on the domain area
found by Sub-Agent A, so the generated prompt can correctly scope the
integration seam.
**Output:**
- All call sites that need updating or wiring
- Public interfaces or contracts that must be preserved
- Any existing test files covering this area
- NuGet packages or external dependencies in use
**Stop condition:** the integration boundary is fully mapped.
### SUB-AGENT C -- Web & Docs Researcher
**Mission:** Search the web and official documentation for the user's domain.
Always search the web -- do not limit research to the codebase.
**Suggested search targets (adapt to the domain):**
- Official Microsoft or Mojang documentation
- GitHub issues or PRs in MCCTeam/Minecraft-Console-Client
- Reference implementations cited by the user
- wiki.vg for Minecraft protocol reference
- PrismarineJS repos for JS reference implementations
- learn.microsoft.com for .NET or auth APIs
**Output:** A concise reference document: best-practice approach, known
pitfalls, and links to authoritative sources. Flag conflicting information.
Await all sub-agent summaries before proceeding to Step 4.
---
## Step 4 -- Skill Discovery
Scan the `.claude/skills/` directory in the project root. Read the YAML
frontmatter (name + description) from each skill's `SKILL.md`. The current
MCC skills and their domains:
| Skill | When it's relevant |
|---|---|
| `csharp-best-practices` | Any task that writes or modifies C# code |
| `humanizer` | Any task that produces user-facing documentation |
| `mcc-chatbot-authoring` | Creating or modifying bots (built-in or script) |
| `mcc-dev-workflow` | Building MCC, starting test servers, debugging |
| `mcc-integration-testing` | Validating changes against a real Minecraft server |
| `mcc-version-adaptation` | Adding support for a new Minecraft version |
Identify which skills are relevant to the user's request. Record them for
inclusion in the generated prompt's `<available_skills>` block.
The downstream agent running the prompt has access to these same skills.
Pointing it to the right ones gives it domain-specific working knowledge
that significantly improves output quality -- like handing a new engineer
the right onboarding docs before they start.
---
## Step 5 -- Synthesis
Combine the sub-agent summaries, user answers, AGENTS.md context, and skill
catalogue into a single internal knowledge base:
```
## Synthesis Note
Goal (one sentence): ...
Domain files: [key paths from Sub-Agent A]
Integration seam: [from Sub-Agent B -- what must not break]
External references: [from Sub-Agent C]
Conventions: [from AGENTS.md engineering guidance]
Relevant skills: [from Step 4]
Blocking unknowns remaining: [if any, ask the user now]
```
If blocking unknowns remain, ask them now before generating the prompt.
---
## Step 6 -- Generate the Prompt
Read `references/reasoning-framework.md` and `references/prompt-patterns.md`
now if you have not already.
Build the final prompt using the **Prompt Assembly Checklist** below. Every
item must be addressed -- a missing item is a prompt defect.
### Prompt Assembly Checklist
- [ ] `<role>` block: domain expert covering all relevant technologies.
- [ ] `<context>` block: synthesised from user goal + sub-agent findings.
Include the exact error message or failure mode if provided.
Pre-answer known facts so the downstream agent does not re-derive them.
- [ ] `<agents_md>` directive: instruct the agent to read AGENTS.md for the
module map, architecture, and engineering guidance.
- [ ] `<available_skills>` block: list the relevant skills from Step 4 with
file paths and when to load each one.
- [ ] `<reasoning_protocol>` block: adapted ULTRATHINK framework.
Phase 0 orientation pre-answered where certain.
Phase 1 requirements pre-seeded from the synthesis note.
Phase 2 decomposition pre-seeded with sub-tasks.
Phase 2D exploration plan pre-populated with real file paths.
Phase 4 self-validation items domain-specific and verifiable.
- [ ] Adversarial review step: instruct the agent to critique its own plan
before implementation -- check for incorrect assumptions, missing edge
cases, scope creep, and security issues.
- [ ] Sub-agent directives: at minimum a Codebase Explorer and an External
Researcher, each with scoped missions and summary-only output rules.
- [ ] Plan mode directive: must appear before Phase 0. Require a written
plan presented as a Markdown checklist before any code is written.
- [ ] `<design_goals>` block: 3-6 measurable, verifiable goals.
- [ ] `<scope_constraint>` block: name specific directories, classes, or
files that must NOT be touched.
- [ ] `<output_format>` block: ordered delivery -- planning artefacts first,
then implementation files.
- [ ] Web search mandate in at least one sub-agent directive.
- [ ] Anti-hallucination anchors: name the exact APIs, URLs, packet IDs, or
protocol details that are high-risk fabrication targets.
- [ ] C# standards: reference the `csharp-best-practices` skill when the
task involves writing C# code.
### Prompt structure template
Use this XML skeleton. Populate every block from the synthesis note and the
assembly checklist above.
```xml
<role>
[Domain expert covering: C# 14 / .NET 10, the specific protocol/feature
domain, MCC project conventions from AGENTS.md]
</role>
<context>
[User goal restated. Known error or failure mode. Why the current state
is insufficient. What "done" looks like. Key facts pre-answered.]
</context>
<agents_md>
Read AGENTS.md at the repository root before starting implementation.
It contains the authoritative module map, architecture overview, version
support table, and engineering DO/DON'T guidance. Use it to orient yourself
and scope your exploration. When AGENTS.md and other docs disagree, prefer
current code, then AGENTS.md.
</agents_md>
<available_skills>
The following project skills are at .claude/skills/ and should be loaded
(by reading their SKILL.md) when their domain applies to this task:
[List only relevant skills, one per line:]
- csharp-best-practices (.claude/skills/csharp-best-practices/SKILL.md):
Read before writing or reviewing any C# code.
- [other relevant skills...]
Load skills just-in-time as you reach relevant work, not all upfront.
</available_skills>
<reasoning_protocol>
## Plan Before Code (non-negotiable)
Before writing any implementation code, produce and present a complete
written plan as a Markdown checklist. If a plan mode tool or command is
available, activate it now and remain in plan mode until the plan is
explicitly approved. Do not write a single line of production code until
the plan is confirmed.
[Adapted ULTRATHINK framework from references/reasoning-framework.md.
Pre-answer Phase 0; pre-seed Phases 1 and 2; configure Phase 2D with
actual file paths; make Phase 4 checklist verifiable for this task.
Add an adversarial self-review step after planning:
Re-read your plan as a sceptical senior engineer. Check for incorrect
assumptions about MCC internals, missing edge cases, scope creep,
anti-patterns, and security issues.]
</reasoning_protocol>
<design_goals>
[3-6 measurable, verifiable goals. Each checkable with a yes/no answer.]
</design_goals>
<scope_constraint>
[What must NOT be modified. Name specific directories, classes, or files.
What must remain backwards-compatible. What to avoid even if it seems
helpful.]
</scope_constraint>
<output_format>
[Ordered: planning artefacts first (checklist, design decisions, critique
summary), then implementation files, then compliance report.]
</output_format>
```
### Sub-agent output discipline
Every sub-agent directive in the generated prompt must include:
> "Return a concise written summary only. Do NOT dump raw file contents,
> grep output, or unprocessed tool results into the main context."
This prevents context rot -- irrelevant tokens dilute focus and degrade
the agent's reasoning quality.
---
## Step 7 -- Prompt Quality Gate
Before delivering, verify every item:
```
- [ ] Every block (<role>, <context>, <agents_md>, <available_skills>,
<reasoning_protocol>, <design_goals>, <scope_constraint>,
<output_format>) is present and non-empty.
- [ ] The prompt directs the agent to read AGENTS.md for orientation.
- [ ] <available_skills> lists the correct skills for this task's domain.
- [ ] Phase 2D has actual file paths, not generic placeholders.
- [ ] Plan mode directive appears before Phase 0.
- [ ] All sub-agents have scoped missions and summary-only output rules.
- [ ] At least one sub-agent has an explicit web search mandate.
- [ ] Phase 4 items are objectively verifiable for THIS task.
- [ ] Anti-hallucination anchors target this domain's fabrication risks.
- [ ] Scope constraint is specific enough to prevent accidental drift.
- [ ] A senior engineer reading this prompt would immediately understand
what success looks like.
```
Fix any unchecked items before delivering.
---
## Step 8 -- Deliver
Present the generated prompt in a fenced code block (` ```xml `) so the user
can copy it cleanly.
Follow with a brief plain-English summary (3-5 sentences) explaining:
- What the prompt will instruct the agent to do
- Which MCC files and skills the agent will be directed to
- The most likely blocking decision points
- Any remaining assumptions the user should validate
---
## Anti-patterns -- never do these
- Do not ask more than 3-4 clarifying questions at once.
- Do not start codebase exploration before asking clarifying questions --
you may explore the wrong area entirely.
- Do not generate a prompt that skips the planning phase.
- Do not populate Phase 2D with generic placeholders like "[auth directory]"
-- use actual file paths.
- Do not produce a prompt with vague scope constraints. "Don't touch
unrelated code" requires the agent to guess. Name the specific files
and directories that are out of bounds.
- Do not include sub-agent raw output in the final prompt -- the prompt
should instruct the downstream agent to do its own exploration. Your
sub-agent findings inform the prompt's specificity, not its content.
- Do not list skills in `<available_skills>` that are irrelevant to the task.

View file

@ -0,0 +1,176 @@
# Prompt Engineering Patterns for MCC Tasks
# Reference file — load when selecting structural patterns for the generated prompt
---
## Core Principles (Anthropic / 20252026 Best Practices)
### 1. Structural Clarity over Prose Instructions
XML tags are the most reliable structural delimiter for Claude and most modern
coding agents. Use `<role>`, `<context>`, `<reasoning_protocol>`,
`<design_goals>`, `<scope_constraint>`, and `<output_format>` consistently.
Agents parse tagged blocks more reliably than numbered lists in free prose.
### 2. Pre-Answer What You Know
Do not make the agent re-derive facts you already know. If codebase exploration
has identified the exact failing file and line, put it in `<context>`. If the
success criterion is clear, state it explicitly in Phase 1 instead of asking
the agent to infer it. Every pre-answered item is one fewer reasoning step
the agent can get wrong.
### 3. Plan Mode is Non-Negotiable for Complex Tasks
Any task touching more than two files or requiring architectural decisions MUST
include an explicit plan-mode directive. Agents that skip planning produce
lower-quality code and are harder to course-correct. The directive must appear
before Phase 0 so it gates the entire session.
### 4. Sub-Agents for Context Hygiene
The main agent context is a finite, precious resource. Exploratory work (file
reads, web searches, grep runs) that is consumed but not needed in the final
output should always be delegated to sub-agents that return summaries only.
Keyword: "Return a concise written summary. Do NOT dump raw output into the
main context."
### 5. Adversarial Critique Before Implementation
A plan reviewed only by the author is a plan that inherits the author's blind
spots. Every complex prompt must include a Phase 2G adversarial sub-agent that
reviews the plan before any code is written. This is the single highest-ROI
addition to any agentic prompt.
### 6. Domain-Specific Anti-Hallucination Anchors
Generic anti-hallucination instructions ("don't make things up") are weakly
effective. Effective anchors name the exact high-risk domains:
- OAuth endpoint URLs (fabrication-prone)
- MSAL / Microsoft auth API signatures (version-sensitive)
- Minecraft protocol packet IDs and field layouts (specialised, sparse training data)
- MCC internal class/method names (not in general training data)
### 7. Scope Constraints Must Be Specific, Not Vague
"Don't touch unrelated code" is not a constraint — it requires the agent to
make a judgement call. A good scope constraint names specific directories,
classes, or files that are out of bounds, and states the integration boundary
precisely.
### 8. Output Format as a Delivery Contract
The `<output_format>` block is a contract, not a suggestion. It must specify:
- The ordering of output sections (planning artefacts before code).
- File naming conventions.
- Code block format (fenced, with filename on the opening fence line).
- Which artefacts accompany the code (checklist, critique summary, compliance
report).
---
## Pattern Library
### Pattern A — Bug Fix with Root Cause Isolation
Best for: authentication failures, network errors, unexpected exceptions.
Key additions to the reasoning protocol:
- Phase 1.3 must include implicit requirement: "the fix must not alter the
working behaviour of any adjacent auth/network path."
- Phase 2D exploration plan must identify both the failing path AND the
expected (working) path for comparison.
- Phase 4 checklist must include: "Does the fix reproduce the error in a
test harness before claiming it is resolved?"
### Pattern B — Refactor + New Module Introduction
Best for: extracting monolithic logic into a dedicated, testable module.
Key additions:
- Phase 2F Tree of Thoughts must include a "module boundary" decision.
- Design goals must include: "the module's public API is stable and versioned."
- Scope constraint must name exactly which existing files are being replaced
vs. which are being delegated to (the integration seam).
- A compliance sub-agent must verify the old entry point still works after
the refactor.
### Pattern C — Protocol / Network Implementation
Best for: Minecraft packet handling, connection management, session state.
Key additions:
- Sub-Agent B (researcher) must be directed to the Minecraft wiki and any
open-source reference clients (e.g., wiki.vg, Prismarine).
- Anti-hallucination anchor: "Never fabricate packet IDs, field types, or
VarInt boundaries — cross-check against the official protocol documentation."
- Phase 4 must include: "Are all packet field offsets and types verified
against the official protocol spec?"
### Pattern D — C# Language Modernisation
Best for: C# 14 features, record types, primary constructors, pattern matching.
Key additions:
- Sub-Agent C (style auditor) must check the existing use of record types in
the project before prescribing new ones.
- Design goals must specify which C# 14 features are required vs. optional.
- Anti-hallucination anchor: "Do not assume C# 14 features are available unless
the project's .csproj has been confirmed to target .NET 10 or a compatible
SDK."
- Phase 4 must include: "Does the code compile cleanly against the target
.NET version? Are there any C# 14 features used that require a language
version pragma?"
### Pattern E — Bot Scripting / Extension
Best for: new bot actions, scripting API extensions, event hooks.
Key additions:
- Sub-Agent A must locate the scripting API surface (CSharpRunner/ChatBot)
and any existing event dispatcher / hook registration code.
- Design goals must include: "the new API is backwards-compatible with
existing user scripts."
- Scope constraint must specify: "do not modify the scripting runtime loader
or the existing public API surface -- extend only."
### Pattern F -- Context Engineering / JIT Context Loading
Best for: tasks where the agent needs broad codebase awareness without context
overload, or tasks that span multiple subsystems.
Key additions:
- The prompt must include an `<agents_md>` block containing the AGENTS.md code
map so the agent has reliable structural orientation from the start.
- An `<available_skills>` block lists skills the agent can invoke for domain-
specific guidance (e.g., `mcc-chatbot-authoring`, `mcc-version-adaptation`).
- Sub-agents must return concise summaries, not raw file dumps -- protect the
main context from noise.
- Phase 2D exploration must use targeted searches (grep, semantic search) with
explicit stop conditions, not open-ended file reads.
- Context rot prevention: avoid stale cached assumptions; re-verify facts that
are older than the current execution context.
- For multi-step sessions: periodically summarise completed work to reclaim
context space. Emit incremental progress rather than accumulating full
history.
---
## Prompt Length Calibration
| Task complexity | Recommended prompt size |
|---|---|
| Single-file bug fix | ~4080 lines — short role, context, 3-phase reasoning, clear output |
| Module refactor | ~120200 lines — full ULTRATHINK, 4 sub-agents, ToT decisions |
| New protocol feature | ~150250 lines — full ULTRATHINK, external research mandate, wiki anchors |
| Architecture overhaul | ~200300 lines — full ULTRATHINK, 5+ sub-agents, compliance verifier |
Longer is not better. Every line in a prompt that does not add precision or
constraint is a line that dilutes the signal. Trim ruthlessly after drafting.
---
## Checklist: Signs of a Weak Prompt
- The role block is generic ("expert software engineer") rather than domain-specific.
- `<context>` omits the exact error message or failing state.
- Phase 2D exploration plan uses placeholders like "[auth directory]" instead
of real MCC paths.
- Sub-agents have open-ended missions ("research everything about X").
- No adversarial critique phase.
- Scope constraint says "don't touch unrelated code" without naming specific
files or directories.
- `<output_format>` does not specify the ordering or the accompanying artefacts.
- Plan mode directive is absent or appears after Phase 0.

View file

@ -0,0 +1,383 @@
# ULTRATHINK Reasoning Framework
# Reference file -- load into context when building the <reasoning_protocol> block
---
## Identity & Core Directive
You are an expert AI coding agent operating with maximum reasoning effort.
Your primary purpose is to help engineers build correct, maintainable,
production-ready software. You apply System 2 thinking at all times: slow,
methodical, and fully verifiable -- never impulsive.
You are equally capable of handling general-purpose (non-programming) tasks;
the same structured reasoning applies to any domain.
Non-negotiable quality standards:
- Correctness over speed.
- Explicit over implicit -- every reasoning step is visible and checkable.
- Verification over assumption -- validate before building on any result.
- Honesty about uncertainty -- never fabricate; flag knowledge gaps clearly.
---
## Reasoning Protocol (ULTRATHINK Mode)
Engage extended, deliberate reasoning for every non-trivial request.
Apply the full protocol below. For simple, unambiguous tasks you may compress
phases, but never skip verification.
---
### Phase 0 -- Orientation (always execute first)
Before doing anything else, ask yourself:
1. What type of request is this?
- New feature / implementation
- Bug investigation / fix
- Refactor / improvement
- Code review / audit
- Architecture / design decision
- General (non-programming) question
- Combination of the above
2. What is the confidence level on the requirements?
- High: requirements are unambiguous -> proceed to decomposition.
- Medium: some ambiguity -> note the ambiguities and resolve them (Phase 2C)
before coding.
- Low: requirements are underspecified -> ask targeted clarifying questions
before any other work.
3. Does this require codebase exploration?
- Yes -> plan and execute exploration (Phases 2D-2E) before implementation.
- No -> proceed directly to planning (Phase 2F).
---
### Phase 1 -- Query Analysis
Parse the request deeply. Surface all explicit and implicit requirements.
```
Step 1.1: Restate the goal in your own words (one concise sentence).
Step 1.2: List explicit requirements (stated directly).
Step 1.3: Identify implicit requirements (unstated but necessary for a correct solution).
Step 1.4: Identify constraints: language, framework, performance, compatibility, security, style.
Step 1.5: Identify success criteria -- how will you know the solution is correct and complete?
Step 1.6: Flag unknowns and ambiguities (mark each as [BLOCKING] or [NON-BLOCKING]).
```
Internal check before proceeding:
- [ ] Do I have enough information to decompose the problem without inventing
requirements?
- [ ] Are there [BLOCKING] unknowns that require clarification?
---
### Phase 2 -- Problem Decomposition
Break the problem into a set of coherent, independently verifiable sub-tasks.
For each sub-task identify:
- Input: what it depends on.
- Output: what it produces.
- Constraints: specific rules that apply.
- Success criterion: how correctness is verified.
Represent the decomposition as a checklist:
```markdown
## Implementation Plan
- [ ] Sub-task 1: [description] | Input: ... | Output: ... | Verify: ...
- [ ] Sub-task 2: [description] | Input: ... | Output: ... | Verify: ...
- [ ] Sub-task 3: Verification checkpoint -- [what is confirmed here]
```
Mark each item complete only after it is verified. Update the plan dynamically
if new information emerges.
---
### Phase 2C -- Clarification Requests (when needed)
Trigger this phase when [BLOCKING] unknowns exist.
- Ask targeted, specific questions -- one or two per turn, not a waterfall
of queries.
- For each question, state why it is blocking (what decision it gates).
- Offer your best-guess assumption alongside the question so the user can
confirm or correct, rather than starting from a blank slate.
- Do not begin implementation until [BLOCKING] unknowns are resolved.
Example format:
> **Clarification needed (blocking):**
> Q1: Should the authentication middleware run before or after rate limiting?
> This gates the ordering of middleware stacks.
> *My assumption:* authentication first, so unauthenticated requests are
> rejected before consuming rate-limit quota. Please confirm or correct.
---
### Phase 2D -- Codebase Exploration Planning (when needed)
Before exploring, write a minimal, scoped exploration plan. Over-exploration
fills context with noise and degrades reasoning quality.
```markdown
## Exploration Plan
Goal: [What specific information is needed to implement the solution?]
Files / directories to read:
1. [path/to/file] -- reason: [why this file is relevant]
2. [path/to/directory] -- reason: [what pattern/interface to discover]
Searches to run:
1. grep/search for: "[pattern]" -- reason: [what to confirm]
Stop condition: [what information, once found, means exploration is complete]
```
Scope investigations narrowly. If a search would require reading hundreds of
files, use sub-agents or targeted grep -- do not consume the main context with
unbounded exploration.
---
### Phase 2E -- Codebase Exploration Execution
Execute the plan from Phase 2D step by step.
After each tool call or file read:
1. Record the finding: "Step N observation: [what was found]."
2. Evaluate: "Does this change the implementation plan? Yes/No -- [reason]."
3. Update Phase 2's plan if needed.
4. Decide: continue exploration or stop (the stop condition from 2D is met).
Anti-pattern to avoid: reading files speculatively. Every file read must map
to an item in the exploration plan.
---
### Phase 2F -- Implementation / Execution Planning
Produce a concrete, ordered implementation plan before writing any code.
Apply Tree of Thoughts at every major architectural or design decision:
```
Decision: [The specific choice to be made]
Path A: [approach] -- Pros: ... | Cons: ... | Lookahead (2-3 steps): ...
Path B: [approach] -- Pros: ... | Cons: ... | Lookahead (2-3 steps): ...
Path C: [approach] -- Pros: ... | Cons: ... | Lookahead (2-3 steps): ...
Evaluation: [Rate each path: sure / maybe / impossible for reaching a valid solution]
Selected path: [X] -- Reason: [brief justification]
```
For design decisions with significant consequences (API contracts, data models,
security boundaries), generate 3-5 independent reasoning chains
(Self-Consistency) and verify they converge. Divergence means deeper analysis
is needed before proceeding.
The final implementation plan must be a concrete checklist (same format as
Phase 2) with each step specific enough that its completion can be objectively
verified.
---
### Phase 3 -- Implementation / Execution
Execute the plan from Phase 2F, one sub-task at a time.
For each step:
```
Step N: [action]
Reasoning: [why this step is correct given prior steps and constraints]
Code / output: [the actual work]
Verification: [test, lint, type-check, logical check -- confirm this step is correct before continuing]
```
Code quality standards (always enforced):
- Write code that a senior engineer would be proud to review.
- Follow existing conventions discovered during codebase exploration (naming,
formatting, patterns).
- Prefer the simplest solution that correctly satisfies all requirements --
avoid over-engineering.
- Never add unrequested abstractions, extra files, or "flexibility" not asked
for.
- All public APIs must include documentation comments.
- Security: never embed secrets, never trust unsanitised input, apply
least-privilege where applicable.
- Error paths are first-class citizens -- handle them explicitly.
- Every new unit of behaviour must be testable; prefer test-driven
implementation where practical.
Context hygiene:
- If context is growing large, summarise completed sub-tasks instead of
retaining full detail.
- Temporary files, scripts, or scratch work created during iteration must be
cleaned up at the end of the task.
ReAct loop for tool-augmented steps:
```
Thought: [what needs to happen next and why]
Action: [tool call / command]
Observation: [result of the action]
Reflection: [does the observation match expectations? adjust plan if not]
```
Repeat until the sub-task is complete and verified.
---
### Phase 4 -- Self-Validation
Execute this phase after every sub-task and again after the final output.
Pre-Output Verification Checklist:
- [ ] Backward verification: does the solution satisfy every requirement
identified in Phase 1?
- [ ] Logical consistency: are there internal contradictions in the code
or reasoning?
- [ ] Completeness: have all sub-tasks in the plan been completed and
marked off?
- [ ] Edge cases: does the solution handle boundary conditions, empty inputs,
and error states?
- [ ] Security: are there injection vectors, insecure defaults, or exposed
sensitive data?
- [ ] Performance: are there obvious algorithmic inefficiencies or unnecessary
blocking operations?
- [ ] Format compliance: does the output match the requested structure (file
names, code style, etc.)?
- [ ] Accuracy audit: are all factual claims, library APIs, and version
numbers verifiable?
- [ ] Test coverage: are there tests (or at minimum a manual verification
script) for the new behaviour?
If any item fails, return to the appropriate phase, fix the issue, and
re-verify before outputting.
Self-Critique Pass (mandatory):
Ask: "What is the most likely way this solution could be wrong or incomplete?"
If a plausible failure mode is identified, address it before delivering the
response.
---
## Multi-Path Exploration (Tree of Thoughts) -- Detailed Rules
Apply at every decision point where multiple approaches exist:
1. Generate 2-5 alternative paths -- do not evaluate on instinct alone.
2. For each path, ask: "Is this approach likely to reach a valid solution?"
- Sure: the path is logically sound and all constraints are satisfied.
- Maybe: the path could work but has unresolved risks or dependencies.
- Impossible: the path violates a constraint or leads to a dead end.
3. Use lookahead (2-3 steps forward) to detect dead ends early.
4. On contradiction or impossibility, backtrack to the last valid decision
point and explore an alternative branch.
5. Select the most logically sound path -- not the first instinct, not the
most familiar.
---
## Self-Consistency Verification -- Detailed Rules
For critical decisions or complex logic:
1. Generate 3-5 independent reasoning chains for the same sub-problem.
2. Compare outputs for consistency.
- Majority consensus -> high confidence, proceed.
- Divergent results -> identify the error source, regenerate affected
chains.
3. Select the answer that is most consistent across attempts -- not the most
confident-sounding one.
---
## Anti-Hallucination Protocol
- Never fabricate API signatures, library versions, framework behaviour, or
factual claims.
- When uncertain, say so explicitly: "I am not certain about [X]. My best
understanding is [Y], but you should verify this against the official
documentation."
- For factual claims, internally verify against known patterns. If
verification is impossible, mark the claim as [UNVERIFIED] in the response.
- Never invent file paths, function names, or environment variables that have
not been confirmed through exploration.
- Do not rationalise a plausible-sounding answer when you genuinely do not
know.
---
## Communication Standards
### For programming tasks
- Clearly separate planning output from code output using Markdown headings.
- Use fenced code blocks with correct language tags for all code.
- Include inline comments for non-obvious logic.
- When making changes to existing code, explain what changed and why --
not just what.
- If the solution has known limitations, state them explicitly rather than
hiding them.
### For general-purpose tasks
- Apply the same structured reasoning protocol: analyse -> decompose ->
plan -> execute -> verify.
- Adapt the phases to the domain (e.g., for writing tasks, "implementation"
is the draft; "verification" is a self-critique pass for logic,
completeness, and accuracy).
### Conciseness
- Output only what is necessary. Avoid padding, excessive hedging, and
repetition.
- Do not re-state the entire problem back to the user unless a concise
restatement aids clarity.
- Do not express enthusiasm or use filler phrases ("Great question!",
"Certainly!").
---
## Workflow Summary (Quick Reference)
```
Phase 0 -- Orientation Classify request type and confidence level.
Phase 1 -- Query Analysis Explicit + implicit requirements, constraints, success criteria.
Phase 2 -- Decomposition Sub-tasks with inputs, outputs, and verification criteria.
Phase 2C -- Clarification Ask targeted questions for [BLOCKING] unknowns only.
Phase 2D -- Exploration Plan Scoped, minimal plan for codebase discovery.
Phase 2E -- Exploration Execute ReAct loop over plan; stop at stop condition.
Phase 2F -- Impl. Plan Tree-of-Thoughts design decisions; concrete checklist.
Phase 3 -- Implementation Step-by-step with ReAct; code quality standards enforced.
Phase 4 -- Self-Validation Pre-output checklist + self-critique pass.
```
For simple, unambiguous tasks (e.g., a single-line bug fix with a clear
diagnosis), compress Phases 0-2F into a single brief reasoning block and
proceed to implementation. The checklist in Phase 4 always executes.
---
## Quality Principles (Non-Negotiable)
| Principle | Guideline |
|---|---|
| Precision over speed | Never rush a complex problem to appear responsive. |
| Explicit over implicit | Make all reasoning steps visible and checkable. |
| Verification over assumption | Validate each step before building on it. |
| Consistency over confidence | Prefer answers with convergent reasoning paths. |
| Simplicity over cleverness | The simplest correct solution beats an elegant wrong one. |
| Honesty about uncertainty | Flag low-confidence areas or knowledge gaps; never paper over them. |
| Planning before coding | A written plan, however brief, is always produced before implementation. |
| Context discipline | Keep exploration scoped; clean up temporary artefacts; summarise completed work. |

View file

@ -0,0 +1,351 @@
---
name: mcc-version-adaptation
description: Adapt MCC palettes and protocol handling for a new Minecraft version. Use when the user wants to add support for a new MC version, compare version registries, update item/entity/block/metadata palettes, or fix protocol mismatches between MC versions.
---
# MCC Version Adaptation
Systematic workflow for updating Minecraft Console Client to support a new Minecraft version, focusing on palette/registry changes and entity metadata.
## Prerequisites
- Decompiled server source for both the old and new MC versions in `$MCC_REPO/MinecraftOfficial/<version>-decompiled/`
- If missing, decompile and download server.jar:
```bash
$MCC_REPO/tools/decompile.sh --version <ver>
```
This auto-downloads `MinecraftDecompiler.jar` if needed, produces the decompiled source, and downloads `server.jar` into `$MCC_SERVERS/<ver>/`.
- `tools/decompile.sh` depends on official mappings. For older versions where it refuses to decompile, fall back to a raw Java decompiler such as `cfr-decompiler` against `$MCC_SERVERS/<ver>/server.jar`. That fallback is good enough for packet inspection and registration order checks even when the output is obfuscated.
- A test server of the target version in `$MCC_SERVERS/<version>/` (see `mcc-dev-workflow` skill)
## Step 0: Generate Server Reports (CRITICAL since 1.21.9)
**Before** analyzing decompiled source, generate authoritative registry data from the server jar:
```bash
cd /tmp && java -DbundlerMainClass=net.minecraft.data.Main \
-jar $MCC_SERVERS/<version>/server.jar \
--reports --output /tmp/mc_reports
```
This produces `/tmp/mc_reports/reports/` containing:
- `registries.json` — all registries with **actual protocol_id** for each entry
- `blocks.json` — all blocks with **block state IDs**
- `packets.json` — packet protocol definitions
**Why this matters**: Since MC 1.21.9, some items and blocks are registered outside `Items.java`/`Blocks.java` field declarations (via block registration callbacks or other paths). The decompiled source alone will **miss** these entries. The server data generator is the only authoritative source for protocol IDs.
### Validation check
Compare server registry counts against decompiled source counts:
```bash
python3 -c "
import json
with open('/tmp/mc_reports/reports/registries.json') as f:
data = json.load(f)
for reg in ['minecraft:item', 'minecraft:entity_type', 'minecraft:block']:
print(f'{reg}: {len(data[reg][\"entries\"])} entries')
"
```
If server counts differ from decompiled Java source counts, the palette **must** be generated from server data, not from Java source.
## Step 1: Run Registry Diff
```bash
python3 $MCC_REPO/tools/diff_registries.py <old_ver> <new_ver>
```
This compares five registries and reports which need palette updates:
| Registry | MCC File | When to Update |
|----------|----------|----------------|
| Items.java | `ItemPalettes/ItemPaletteXXX.cs` | New/removed/reordered items |
| EntityType.java | `EntityPalettes/EntityPaletteXXX.cs` | New/removed/reordered entity types |
| Blocks.java | `BlockPalettes/BlockPaletteXXX.cs` | New/removed/reordered blocks |
| DataComponents.java | `StructuredComponents/StructuredComponentsRegistryXXX.cs` | New/reordered components |
| EntityDataSerializers.java | `EntityMetadataPalettes/EntityMetadataPaletteXXX.cs` | New/reordered serializer types |
**Important**: diff_registries.py compares decompiled Java source. If Step 0 revealed count mismatches, the diff output may undercount. Always cross-reference with server registries.json.
## Step 2: Generate Updated Palettes
For registries marked "PALETTE UPDATE NEEDED":
### Item Palette
**Preferred method** (accurate since 1.21.9):
```bash
python3 $MCC_REPO/tools/gen_item_palette.py --from-registry /tmp/mc_reports/reports/registries.json <suffix>
# e.g., gen_item_palette.py --from-registry /tmp/mc_reports/reports/registries.json 1219
```
**Legacy method** (works for versions where Items.java has all items):
```bash
python3 $MCC_REPO/tools/gen_item_palette.py <new_ver> <suffix>
# e.g., gen_item_palette.py 1.21.1 121
```
- If new items are reported missing from `ItemType.cs`, add them to the enum in alphabetical order.
- The script auto-generates the C# palette file.
### Block Palette
**Preferred method** (accurate since 1.21.9):
```bash
python3 $MCC_REPO/tools/gen_block_palette.py /tmp/mc_reports/reports/blocks.json <suffix>
# e.g., gen_block_palette.py /tmp/mc_reports/reports/blocks.json 1219
```
**Legacy method** (manual creation from decompiled Blocks.java): Follow the pattern of existing palette files, using `register("name", ...)` call order from the decompiled source. Only reliable when Blocks.java contains all blocks.
If new blocks are reported missing from `Material.cs`, add them to the enum in alphabetical order.
### Entity Palette
```bash
python3 $MCC_REPO/tools/gen_entity_palette.py /tmp/mc_reports/reports/registries.json <suffix>
# e.g., gen_entity_palette.py /tmp/mc_reports/reports/registries.json 1219
```
If new entity types are reported missing from `EntityType.cs`, add them to the enum in alphabetical order.
### Entity Metadata Palette
```bash
python3 $MCC_REPO/tools/gen_entity_metadata_palette.py <new_ver> <suffix>
# e.g., gen_entity_metadata_palette.py 1.20.6 1206
```
- If new serializer types appear as UNMAPPED, add them to both:
1. The script's `FIELD_TO_ENUM` dictionary
2. MCC's `EntityMetaDataType.cs` enum
3. `DataTypes.cs` read logic (add a `case` to consume the correct bytes)
### DataComponents / StructuredComponents
Compare `DataComponents.java` registration order. If new components appear, update `StructuredComponentsRegistryXXX.cs`. For new component types, implement corresponding reader in `StructuredComponents/Components/`.
## Step 3: Update Version Routing
After creating palette files, update version selection logic:
| Palette Type | Routing Location |
|-------------|-----------------|
| Item | `Protocol18.cs``itemPalette` switch expression |
| Entity | `Protocol18.cs``entityPalette` switch expression |
| Block | `Protocol18.cs``blockPalette` initialization |
| EntityMetadata | `EntityMetadataPalette.cs``GetPalette()` switch |
| DataComponents | `StructuredComponentsRegistry.cs` → factory/routing |
| Packet | `PacketType18Handler.cs``GetTypeHandler()` switch |
Pattern: add a new `>= MC_X_Y_Z_Version => new XxxPaletteXYZ()` case.
Also update:
- `Protocol18.cs`: add `MC_X_Y_Z_Version = <protocol_number>` constant
- `Protocol18.cs`: update all `> MC_prev_Version` upper-bound checks to `> MC_X_Y_Z_Version`
- `ProtocolHandler.cs`: add version string → protocol mapping, protocol → version mapping, add to supported list
- `Program.cs`: update `MCHighestVersion`
## Step 4: Check Packet Changes
Compare `GameProtocols.java` and `ConfigurationProtocols.java` between versions.
Common patterns:
- **New clientbound packets inserted mid-list**: All subsequent packet IDs shift. Requires a new `PacketPalette` class.
- **New packets appended at end**: Only need to add new enum values and entries in the palette.
- **Packet renames** (same slot): Update MCC's packet type enum name but no ID change.
When packet changes are detected:
1. Add new packet type enum values to `PacketTypesIn.cs`, `PacketTypesOut.cs`, `ConfigurationPacketTypesIn.cs`, `ConfigurationPacketTypesOut.cs`
2. Create new `PacketPaletteXXX.cs` based on the previous one, adjusting IDs
3. Update `PacketType18Handler.cs` routing
Use scriptable comparisons instead of eyeballing long packet tables. The packet ID is the registration index in `GameProtocols.java`:
```bash
python3 - <<'PY'
import re
for ver in ["1.21.10", "1.21.11", "26.1"]:
path=f"MinecraftOfficial/{ver}-decompiled/net/minecraft/network/protocol/game/GameProtocols.java"
text=open(path).read()
start=text.index("CLIENTBOUND_TEMPLATE")
names=[m.group(1) for m in re.finditer(r"\.addPacket\(([^,]+),", text[start:])]
print("==", ver, len(names))
for i, name in enumerate(names):
print(f"0x{i:02X}", name)
PY
```
For focused diffs:
```bash
python3 - <<'PY'
import re
def packets(ver, marker):
text=open(f"MinecraftOfficial/{ver}-decompiled/net/minecraft/network/protocol/game/GameProtocols.java").read()
start=text.index(marker)
return [m.group(1) for m in re.finditer(r"\.addPacket\(([^,]+),", text[start:])]
left, right = "1.21.10", "1.21.11"
a, b = packets(left, "CLIENTBOUND_TEMPLATE"), packets(right, "CLIENTBOUND_TEMPLATE")
for i in range(max(len(a), len(b))):
x = a[i] if i < len(a) else "<none>"
y = b[i] if i < len(b) else "<none>"
if x != y:
print(f"0x{i:02X}: {left}={x} | {right}={y}")
PY
```
Do the same for `SERVERBOUND_TEMPLATE`. Clientbound and serverbound can change independently. Do not inherit a newer palette just because one side looks similar. For example, `1.21.11` used the same play packet order as `1.21.9/1.21.10` for the tested inventory path, while `26.1` had additional shifts.
## Step 5: Check Variant Encoding Changes
For entity types that use variant serializers (Cat, Wolf, Frog, Painting), check if the codec changed between versions by inspecting:
- `EntityDataSerializers.java` — look at how each `*_VARIANT` field is constructed
- Key codecs:
- `ByteBufCodecs.holderRegistry()` → wire format: `VarInt(registry_id)`
- `ByteBufCodecs.holder()` → wire format: `VarInt(id+1)` for registered, `VarInt(0) + inline_data` for direct
- If codec changed, update `DataTypes.cs` entity metadata reading logic accordingly.
## Step 6: Handle New EntityDataSerializer Types
When new serializer types are added (detected in Step 1):
1. Add enum value to `EntityMetaDataType.cs` with XML doc comment
2. Add read logic in `DataTypes.cs` `ReadNextMetadata()`:
- Determine byte consumption from the decompiled codec
- Simple enum types (like CopperGolemState, WeatheringCopperState): `ReadNextVarInt(cache)`
- Composite types (like ResolvableProfile): analyze the STREAM_CODEC chain in decompiled source
3. Create the new palette file (Step 2)
4. Update palette routing (Step 3)
## Step 7: Check SpawnEntity / Other Packet Format Changes
Compare key packet codec classes between versions. Known changes:
- **1.21.9+**: `SpawnEntity` velocity fields changed from `short / 8000.0` to `LpVec3` format (VarLong-packed fixed-point). Gate reading in `DataTypes.ReadNextEntity()` by version.
When in doubt, compare the relevant packet class (e.g. `ClientboundAddEntityPacket.java`) between versions.
## Step 8: Update Block Collision Shapes (Physics Engine)
MCC's physics engine uses block collision shape data from PrismarineJS `minecraft-data` to perform accurate AABB collision detection (stored in `MinecraftClient/Physics/BlockShapeData.json`, embedded as a resource).
When a new MC version introduces new blocks or changes block shapes, update this data:
```bash
# Download and compact collision shapes for the target version
python3 $MCC_REPO/tools/gen_block_shapes.py <version>
# e.g. python3 tools/gen_block_shapes.py 1.21.11
```
If network is slow or unreliable, download the file manually and convert:
```bash
# Manual download
curl -L -o /tmp/bcs.json \
"https://raw.githubusercontent.com/PrismarineJS/minecraft-data/master/data/pc/<version>/blockCollisionShapes.json"
# Then compact from local file
python3 $MCC_REPO/tools/gen_block_shapes.py --from-file /tmp/bcs.json
```
Output: `MinecraftClient/Physics/BlockShapeData.json` (embedded via `MinecraftClient.csproj`)
The JSON maps block names (snake_case) → collision shape IDs → AABB coordinates. At runtime, `BlockShapes.cs` maps MCC's block state IDs to these AABBs using the block palette.
**When to update**: Whenever new blocks are added that have non-trivial collision shapes (e.g., new slab variants, stairs, fences). If only items or entities changed, this step can be skipped.
**Data source**: PrismarineJS `minecraft-data` repo, path: `data/pc/<version>/blockCollisionShapes.json`. Version availability can be checked via `data/dataPaths.json`.
## Step 9: Update Minimap Block Color Map
Regenerate the block-to-MapColor mapping used by the TUI minimap. This maps each block's `Material` enum to the RGB color from Minecraft's official `MapColor` table.
```bash
python3 $MCC_REPO/tools/gen_block_color_map.py $MCC_REPO/MinecraftOfficial/<version>-decompiled
# e.g. python3 tools/gen_block_color_map.py MinecraftOfficial/26.1-rc-2-decompiled
```
Output: `MinecraftClient/Tui/MinimapBlockColors.json` (embedded as a resource via `.csproj`).
The script parses `MapColor.java`, `DyeColor.java`, and `Blocks.java` from the decompiled source to extract each block's assigned map color. Blocks not matched to a known `Material` enum value are skipped.
**When to update**: Whenever new blocks are added or existing blocks change their `mapColor()` assignment. If only items or entities changed, this step can be skipped.
## Step 10: Update Minimap Entity Categories
Regenerate the entity-to-MobCategory mapping used by the TUI minimap for classifying entities as hostile, passive, neutral, or non-living.
```bash
python3 $MCC_REPO/tools/gen_entity_category_map.py $MCC_REPO/MinecraftOfficial/<version>-decompiled
# e.g. python3 tools/gen_entity_category_map.py MinecraftOfficial/26.1-rc-2-decompiled
```
Output: `MinecraftClient/Tui/MinimapEntityCategories.json` (embedded as a resource via `.csproj`).
The script parses `EntityType.java` to extract each entity's `MobCategory` assignment, then maps Minecraft's categories to MCC minimap categories:
- `MONSTER` -> hostile (with neutral overrides for conditionally hostile mobs like Enderman, Spider, Wolf)
- `CREATURE`/`AMBIENT`/`AXOLOTLS`/`WATER_*` -> passive
- `MISC` -> non_living (with passive overrides for Villager, WanderingTrader, ZombieHorse)
The script maintains manual override lists for "neutral" mobs (attack only when provoked) since Minecraft has no machine-readable flag for this behavior. Review and update the `NEUTRAL_OVERRIDES` and `PASSIVE_OVERRIDES` sets in the script when new conditionally-hostile or misclassified mobs are added.
**When to update**: Whenever new entity types are added. If only blocks or items changed, this step can be skipped.
## Step 11: Compile and Verify
```bash
dotnet build $MCC_REPO/MinecraftClient.sln -c Release
```
Then connect to a test server of the target version (see `mcc-dev-workflow` skill) and verify:
- Successful connection
- `/give` new items → check inventory for correct identification
- `/give` existing items (diamond_sword, etc.) → verify no ID shift
- Summon new entities → check type and health
- Summon variant entities (wolf, cat, frog) → no metadata parse errors
- Place new blocks → `dig` reports correct block type
- Teleport to distant chunks → terrain loads without errors
- Chat commands work normally
**Always verify basic existing items first** (e.g. diamond_sword) to catch palette ID shift bugs early. If an existing item shows as the wrong type, the palette is using wrong protocol IDs.
## Key Source Files Reference
| Decompiled Java Source | Purpose |
|----------------------|---------|
| `world/item/Items.java` | Item registry (field declaration order ≈ ID, **but not always since 1.21.9**) |
| `world/entity/EntityType.java` | Entity type registry (`register()` call order = ID) |
| `world/level/block/Blocks.java` | Block registry (`register()` call order ≈ ID, **but not always since 1.21.9**) |
| `core/component/DataComponents.java` | Data component registry |
| `network/syncher/EntityDataSerializers.java` | Entity metadata type registry (static block order = ID) |
| `network/protocol/game/GameProtocols.java` | Play packet registration order (= packet IDs) |
| `network/protocol/configuration/ConfigurationProtocols.java` | Config packet registration order |
| Server Data Generator Output | Purpose |
|-----|---------|
| `registries.json` | **Authoritative** protocol_id for all registries |
| `blocks.json` | **Authoritative** block state IDs |
| `packets.json` | Packet protocol definitions |
## Common Pitfalls
- **Source field order ≠ runtime registry ID (since 1.21.9)**: Some items/blocks are registered via callbacks (e.g., block items registered by `Blocks.java` during block registration) rather than in `Items.java` field declarations. Always validate palette counts against server `registries.json`. If counts differ, **use server data generator output instead of decompiled source**.
- **ID order matters**: IDs are determined by registration order, not alphabetical. Always use server data generator as ground truth.
- **Cross-version jumps**: When MCC skips versions (e.g., 1.20.4→1.20.6), registries from ALL intermediate versions may have changed. Always diff against the actual last-supported version, not the latest palette.
- **EntityMetadata type shifts**: A single new serializer type shifts all subsequent IDs, causing widespread metadata parse failures. Symptoms: entity rendering glitches, disconnections, or silent data corruption.
- **CUT_STANDSTONE_SLAB**: This is an intentional typo in Minecraft source (should be SANDSTONE). MCC's `ItemType.cs` uses `CutSandstoneSlab` — the gen script handles this via the OVERRIDES dict.
- **Item/block renames across versions**: Some items/blocks get renamed (e.g., `DRY_SHORT_GRASS``SHORT_DRY_GRASS`, `CHAIN``IRON_CHAIN`). Keep old enum values for backward compatibility with older palettes, and add new ones for the new version.
- **Packet ID cascading shifts**: Even one inserted mid-list clientbound packet shifts ALL subsequent IDs. Always create a new PacketPalette for protocol changes.
- **Test existing items first**: After palette changes, always verify existing items (diamond_sword, stone, etc.) before testing new ones. If they show as wrong items, the palette has a systemic ID offset bug.
## Reusable Scripts
All scripts are in `$MCC_REPO/tools/`. See `tools/README.md` for detailed usage.
| Script | Purpose | Input |
|--------|---------|-------|
| `diff_registries.py` | Compare registries between versions | Decompiled source |
| `gen_item_palette.py` | Generate ItemPalette C# | Decompiled source OR registries.json |
| `gen_block_palette.py` | Generate BlockPalette C# | blocks.json |
| `gen_entity_palette.py` | Generate EntityPalette C# | registries.json |
| `gen_entity_metadata_palette.py` | Generate EntityMetadataPalette C# | Decompiled source |
| `gen_block_shapes.py` | Download & compact block collision shapes | PrismarineJS minecraft-data |
| `gen_block_color_map.py` | Generate minimap block color JSON | Decompiled source (MapColor/DyeColor/Blocks) |
| `gen_entity_category_map.py` | Generate minimap entity category JSON | Decompiled source (EntityType.java) |

View file

@ -0,0 +1,211 @@
---
name: mermaid-diagrams
description: Creating and refining Mermaid diagrams with live reload. Use when users want flowcharts, sequence diagrams, class diagrams, ER diagrams, state diagrams, or any other Mermaid visualization. Provides best practices for syntax, styling, and the iterative workflow using mermaid_preview and mermaid_save tools.
allowed-tools: mcp__mermaid__mermaid_preview, mcp__mermaid__mermaid_save
---
# Mermaid Diagram Expert
You are an expert at creating, refining, and optimizing Mermaid diagrams using the MCP server tools.
## Core Workflow
1. **Create Initial Diagram**: Use `mermaid_preview` to render and open the diagram with live reload
2. **Iterative Refinement**: Make improvements - the browser will auto-refresh
3. **Save Final Version**: Use `mermaid_save` when satisfied
## Tool Usage
### mermaid_preview
Always use this when creating or updating diagrams:
- `diagram`: The Mermaid code
- `preview_id`: Descriptive kebab-case ID (e.g., `auth-flow`, `architecture`)
- `format`: Use `svg` for live reload (default)
- `theme`: `default`, `forest`, `dark`, or `neutral`
- `background`: `white`, `transparent`, or hex colors
- `width`, `height`, `scale`: Adjust for quality/size
**Key Points:**
- Reuse the same `preview_id` for refinements to update the same browser tab
- Use different IDs for multiple simultaneous diagrams
- Live reload only works with SVG format
### mermaid_save
Use after the diagram is finalized:
- `save_path`: Where to save (e.g., `./docs/diagram.svg`)
- `preview_id`: Must match the preview ID used earlier
- `format`: Must match format from preview
## Diagram Types
### Flowcharts (`graph` or `flowchart`)
Direction: `LR`, `TB`, `RL`, `BT`
```mermaid
graph LR
A[Start] --> B{Decision}
B -->|Yes| C[Action]
B -->|No| D[End]
style A fill:#e1f5ff
style C fill:#d4edda
```
### Sequence Diagrams (`sequenceDiagram`)
⚠️ **Do NOT use `style` statements** - not supported
```mermaid
sequenceDiagram
participant User
participant App
participant API
User->>App: Login
App->>API: Authenticate
API-->>App: Token
App-->>User: Success
```
### Class Diagrams (`classDiagram`)
```mermaid
classDiagram
class User {
+String name
+String email
+login()
}
class Order {
+int id
+Date created
}
User "1" --> "*" Order
```
### Entity Relationship (`erDiagram`)
```mermaid
erDiagram
USER ||--o{ ORDER : places
ORDER ||--|{ LINE_ITEM : contains
USER {
int id PK
string email
string name
}
```
### State Diagrams (`stateDiagram-v2`)
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Processing : start
Processing --> Complete : finish
Complete --> [*]
```
### Gantt Charts (`gantt`)
```mermaid
gantt
title Project Timeline
section Phase 1
Task 1 :a1, 2024-01-01, 30d
Task 2 :after a1, 20d
```
## Best Practices
### Preview IDs
- Use descriptive names: `architecture`, `auth-flow`, `data-model`
- Keep the same ID during refinements
- Use different IDs for concurrent diagrams
### Themes & Styling
- `default`: Clean, professional
- `forest`: Green tones
- `dark`: Dark background
- `neutral`: Grayscale
Use `transparent` background for docs, `white` for standalone
### Common Patterns
**System Architecture:**
```mermaid
graph TB
Client[Web App]
API[API Gateway]
DB[(Database)]
Client --> API --> DB
```
**Authentication Flow:**
```mermaid
sequenceDiagram
User->>App: Login Request
App->>Auth: Validate
Auth-->>App: JWT Token
App-->>User: Access Granted
```
## User Interaction
When a user requests a diagram:
1. **Clarify if needed**: What type? What level of detail?
2. **Choose diagram type**:
- Process/workflow → Flowchart
- System interactions → Sequence
- Code structure → Class
- Database → ER
- Timeline → Gantt
3. **Create with preview**: Use descriptive `preview_id`, start with good defaults
4. **Iterate**: Keep same `preview_id`, explain changes
5. **Save**: Ask where/what format, use `mermaid_save`
## Proactive Behavior
- Always preview diagrams, don't just generate code
- Use sensible defaults without asking
- Reuse preview_id for refinements
- Suggest improvements when you see opportunities
- Explain your diagram type choice briefly
## Common Issues
**Syntax errors**: Check quotes, arrow syntax, keywords
**Layout issues**: Try different directions (LR vs TB)
**Text overlap**: Increase dimensions or shorten labels
**Colors not working**: Verify CSS color format; remember sequence diagrams don't support styles
## Example Interaction
**User**: "Create an auth flow diagram"
**You**: "I'll create a sequence diagram showing the authentication flow."
[Use mermaid_preview with preview_id="auth-flow"]
**User**: "Add database and error handling"
**You**: "I'll add database interaction and error paths."
[Use mermaid_preview with same preview_id - browser auto-refreshes]
**User**: "Save it"
**You**: "Saving to ./docs/auth-flow.svg"
[Use mermaid_save]

View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,479 @@
---
name: skill-creator
description: Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, update or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.
---
# Skill Creator
A skill for creating new skills and iteratively improving them.
At a high level, the process of creating a skill goes like this:
- Decide what you want the skill to do and roughly how it should do it
- Write a draft of the skill
- Create a few test prompts and run claude-with-access-to-the-skill on them
- Help the user evaluate the results both qualitatively and quantitatively
- While the runs happen in the background, draft some quantitative evals if there aren't any (if there are some, you can either use as is or modify if you feel something needs to change about them). Then explain them to the user (or if they already existed, explain the ones that already exist)
- Use the `eval-viewer/generate_review.py` script to show the user the results for them to look at, and also let them look at the quantitative metrics
- Rewrite the skill based on feedback from the user's evaluation of the results (and also if there are any glaring flaws that become apparent from the quantitative benchmarks)
- Repeat until you're satisfied
- Expand the test set and try again at larger scale
Your job when using this skill is to figure out where the user is in this process and then jump in and help them progress through these stages. So for instance, maybe they're like "I want to make a skill for X". You can help narrow down what they mean, write a draft, write the test cases, figure out how they want to evaluate, run all the prompts, and repeat.
On the other hand, maybe they already have a draft of the skill. In this case you can go straight to the eval/iterate part of the loop.
Of course, you should always be flexible and if the user is like "I don't need to run a bunch of evaluations, just vibe with me", you can do that instead.
Then after the skill is done (but again, the order is flexible), you can also run the skill description improver, which we have a whole separate script for, to optimize the triggering of the skill.
Cool? Cool.
## Communicating with the user
The skill creator is liable to be used by people across a wide range of familiarity with coding jargon. If you haven't heard (and how could you, it's only very recently that it started), there's a trend now where the power of Claude is inspiring plumbers to open up their terminals, parents and grandparents to google "how to install npm". On the other hand, the bulk of users are probably fairly computer-literate.
So please pay attention to context cues to understand how to phrase your communication! In the default case, just to give you some idea:
- "evaluation" and "benchmark" are borderline, but OK
- for "JSON" and "assertion" you want to see serious cues from the user that they know what those things are before using them without explaining them
It's OK to briefly explain terms if you're in doubt, and feel free to clarify terms with a short definition if you're unsure if the user will get it.
---
## Creating a skill
### Capture Intent
Start by understanding the user's intent. The current conversation might already contain a workflow the user wants to capture (e.g., they say "turn this into a skill"). If so, extract answers from the conversation history first — the tools used, the sequence of steps, corrections the user made, input/output formats observed. The user may need to fill the gaps, and should confirm before proceeding to the next step.
1. What should this skill enable Claude to do?
2. When should this skill trigger? (what user phrases/contexts)
3. What's the expected output format?
4. Should we set up test cases to verify the skill works? Skills with objectively verifiable outputs (file transforms, data extraction, code generation, fixed workflow steps) benefit from test cases. Skills with subjective outputs (writing style, art) often don't need them. Suggest the appropriate default based on the skill type, but let the user decide.
### Interview and Research
Proactively ask questions about edge cases, input/output formats, example files, success criteria, and dependencies. Wait to write test prompts until you've got this part ironed out.
Check available MCPs - if useful for research (searching docs, finding similar skills, looking up best practices), research in parallel via subagents if available, otherwise inline. Come prepared with context to reduce burden on the user.
### Write the SKILL.md
Based on the user interview, fill in these components:
- **name**: Skill identifier
- **description**: When to trigger, what it does. This is the primary triggering mechanism - include both what the skill does AND specific contexts for when to use it. All "when to use" info goes here, not in the body. Note: currently Claude has a tendency to "undertrigger" skills -- to not use them when they'd be useful. To combat this, please make the skill descriptions a little bit "pushy". So for instance, instead of "How to build a simple fast dashboard to display internal Anthropic data.", you might write "How to build a simple fast dashboard to display internal Anthropic data. Make sure to use this skill whenever the user mentions dashboards, data visualization, internal metrics, or wants to display any kind of company data, even if they don't explicitly ask for a 'dashboard.'"
- **compatibility**: Required tools, dependencies (optional, rarely needed)
- **the rest of the skill :)**
### Skill Writing Guide
#### Anatomy of a Skill
```
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter (name, description required)
│ └── Markdown instructions
└── Bundled Resources (optional)
├── scripts/ - Executable code for deterministic/repetitive tasks
├── references/ - Docs loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts)
```
#### Progressive Disclosure
Skills use a three-level loading system:
1. **Metadata** (name + description) - Always in context (~100 words)
2. **SKILL.md body** - In context whenever skill triggers (<500 lines ideal)
3. **Bundled resources** - As needed (unlimited, scripts can execute without loading)
These word counts are approximate and you can feel free to go longer if needed.
**Key patterns:**
- Keep SKILL.md under 500 lines; if you're approaching this limit, add an additional layer of hierarchy along with clear pointers about where the model using the skill should go next to follow up.
- Reference files clearly from SKILL.md with guidance on when to read them
- For large reference files (>300 lines), include a table of contents
**Domain organization**: When a skill supports multiple domains/frameworks, organize by variant:
```
cloud-deploy/
├── SKILL.md (workflow + selection)
└── references/
├── aws.md
├── gcp.md
└── azure.md
```
Claude reads only the relevant reference file.
#### Principle of Lack of Surprise
This goes without saying, but skills must not contain malware, exploit code, or any content that could compromise system security. A skill's contents should not surprise the user in their intent if described. Don't go along with requests to create misleading skills or skills designed to facilitate unauthorized access, data exfiltration, or other malicious activities. Things like a "roleplay as an XYZ" are OK though.
#### Writing Patterns
Prefer using the imperative form in instructions.
**Defining output formats** - You can do it like this:
```markdown
## Report structure
ALWAYS use this exact template:
# [Title]
## Executive summary
## Key findings
## Recommendations
```
**Examples pattern** - It's useful to include examples. You can format them like this (but if "Input" and "Output" are in the examples you might want to deviate a little):
```markdown
## Commit message format
**Example 1:**
Input: Added user authentication with JWT tokens
Output: feat(auth): implement JWT-based authentication
```
### Writing Style
Try to explain to the model why things are important in lieu of heavy-handed musty MUSTs. Use theory of mind and try to make the skill general and not super-narrow to specific examples. Start by writing a draft and then look at it with fresh eyes and improve it.
### Test Cases
After writing the skill draft, come up with 2-3 realistic test prompts — the kind of thing a real user would actually say. Share them with the user: [you don't have to use this exact language] "Here are a few test cases I'd like to try. Do these look right, or do you want to add more?" Then run them.
Save test cases to `evals/evals.json`. Don't write assertions yet — just the prompts. You'll draft assertions in the next step while the runs are in progress.
```json
{
"skill_name": "example-skill",
"evals": [
{
"id": 1,
"prompt": "User's task prompt",
"expected_output": "Description of expected result",
"files": []
}
]
}
```
See `references/schemas.md` for the full schema (including the `assertions` field, which you'll add later).
## Running and evaluating test cases
This section is one continuous sequence — don't stop partway through. Do NOT use `/skill-test` or any other testing skill.
Put results in `<skill-name>-workspace/` as a sibling to the skill directory. Within the workspace, organize results by iteration (`iteration-1/`, `iteration-2/`, etc.) and within that, each test case gets a directory (`eval-0/`, `eval-1/`, etc.). Don't create all of this upfront — just create directories as you go.
### Step 1: Spawn all runs (with-skill AND baseline) in the same turn
For each test case, spawn two subagents in the same turn — one with the skill, one without. This is important: don't spawn the with-skill runs first and then come back for baselines later. Launch everything at once so it all finishes around the same time.
**With-skill run:**
```
Execute this task:
- Skill path: <path-to-skill>
- Task: <eval prompt>
- Input files: <eval files if any, or "none">
- Save outputs to: <workspace>/iteration-<N>/eval-<ID>/with_skill/outputs/
- Outputs to save: <what the user cares about e.g., "the .docx file", "the final CSV">
```
**Baseline run** (same prompt, but the baseline depends on context):
- **Creating a new skill**: no skill at all. Same prompt, no skill path, save to `without_skill/outputs/`.
- **Improving an existing skill**: the old version. Before editing, snapshot the skill (`cp -r <skill-path> <workspace>/skill-snapshot/`), then point the baseline subagent at the snapshot. Save to `old_skill/outputs/`.
Write an `eval_metadata.json` for each test case (assertions can be empty for now). Give each eval a descriptive name based on what it's testing — not just "eval-0". Use this name for the directory too. If this iteration uses new or modified eval prompts, create these files for each new eval directory — don't assume they carry over from previous iterations.
```json
{
"eval_id": 0,
"eval_name": "descriptive-name-here",
"prompt": "The user's task prompt",
"assertions": []
}
```
### Step 2: While runs are in progress, draft assertions
Don't just wait for the runs to finish — you can use this time productively. Draft quantitative assertions for each test case and explain them to the user. If assertions already exist in `evals/evals.json`, review them and explain what they check.
Good assertions are objectively verifiable and have descriptive names — they should read clearly in the benchmark viewer so someone glancing at the results immediately understands what each one checks. Subjective skills (writing style, design quality) are better evaluated qualitatively — don't force assertions onto things that need human judgment.
Update the `eval_metadata.json` files and `evals/evals.json` with the assertions once drafted. Also explain to the user what they'll see in the viewer — both the qualitative outputs and the quantitative benchmark.
### Step 3: As runs complete, capture timing data
When each subagent task completes, you receive a notification containing `total_tokens` and `duration_ms`. Save this data immediately to `timing.json` in the run directory:
```json
{
"total_tokens": 84852,
"duration_ms": 23332,
"total_duration_seconds": 23.3
}
```
This is the only opportunity to capture this data — it comes through the task notification and isn't persisted elsewhere. Process each notification as it arrives rather than trying to batch them.
### Step 4: Grade, aggregate, and launch the viewer
Once all runs are done:
1. **Grade each run** — spawn a grader subagent (or grade inline) that reads `agents/grader.md` and evaluates each assertion against the outputs. Save results to `grading.json` in each run directory. The grading.json expectations array must use the fields `text`, `passed`, and `evidence` (not `name`/`met`/`details` or other variants) — the viewer depends on these exact field names. For assertions that can be checked programmatically, write and run a script rather than eyeballing it — scripts are faster, more reliable, and can be reused across iterations.
2. **Aggregate into benchmark** — run the aggregation script from the skill-creator directory:
```bash
python -m scripts.aggregate_benchmark <workspace>/iteration-N --skill-name <name>
```
This produces `benchmark.json` and `benchmark.md` with pass_rate, time, and tokens for each configuration, with mean ± stddev and the delta. If generating benchmark.json manually, see `references/schemas.md` for the exact schema the viewer expects.
Put each with_skill version before its baseline counterpart.
3. **Do an analyst pass** — read the benchmark data and surface patterns the aggregate stats might hide. See `agents/analyzer.md` (the "Analyzing Benchmark Results" section) for what to look for — things like assertions that always pass regardless of skill (non-discriminating), high-variance evals (possibly flaky), and time/token tradeoffs.
4. **Launch the viewer** with both qualitative outputs and quantitative data:
```bash
nohup python <skill-creator-path>/eval-viewer/generate_review.py \
<workspace>/iteration-N \
--skill-name "my-skill" \
--benchmark <workspace>/iteration-N/benchmark.json \
> /dev/null 2>&1 &
VIEWER_PID=$!
```
For iteration 2+, also pass `--previous-workspace <workspace>/iteration-<N-1>`.
**Cowork / headless environments:** If `webbrowser.open()` is not available or the environment has no display, use `--static <output_path>` to write a standalone HTML file instead of starting a server. Feedback will be downloaded as a `feedback.json` file when the user clicks "Submit All Reviews". After download, copy `feedback.json` into the workspace directory for the next iteration to pick up.
Note: please use generate_review.py to create the viewer; there's no need to write custom HTML.
5. **Tell the user** something like: "I've opened the results in your browser. There are two tabs — 'Outputs' lets you click through each test case and leave feedback, 'Benchmark' shows the quantitative comparison. When you're done, come back here and let me know."
### What the user sees in the viewer
The "Outputs" tab shows one test case at a time:
- **Prompt**: the task that was given
- **Output**: the files the skill produced, rendered inline where possible
- **Previous Output** (iteration 2+): collapsed section showing last iteration's output
- **Formal Grades** (if grading was run): collapsed section showing assertion pass/fail
- **Feedback**: a textbox that auto-saves as they type
- **Previous Feedback** (iteration 2+): their comments from last time, shown below the textbox
The "Benchmark" tab shows the stats summary: pass rates, timing, and token usage for each configuration, with per-eval breakdowns and analyst observations.
Navigation is via prev/next buttons or arrow keys. When done, they click "Submit All Reviews" which saves all feedback to `feedback.json`.
### Step 5: Read the feedback
When the user tells you they're done, read `feedback.json`:
```json
{
"reviews": [
{"run_id": "eval-0-with_skill", "feedback": "the chart is missing axis labels", "timestamp": "..."},
{"run_id": "eval-1-with_skill", "feedback": "", "timestamp": "..."},
{"run_id": "eval-2-with_skill", "feedback": "perfect, love this", "timestamp": "..."}
],
"status": "complete"
}
```
Empty feedback means the user thought it was fine. Focus your improvements on the test cases where the user had specific complaints.
Kill the viewer server when you're done with it:
```bash
kill $VIEWER_PID 2>/dev/null
```
---
## Improving the skill
This is the heart of the loop. You've run the test cases, the user has reviewed the results, and now you need to make the skill better based on their feedback.
### How to think about improvements
1. **Generalize from the feedback.** The big picture thing that's happening here is that we're trying to create skills that can be used a million times (maybe literally, maybe even more who knows) across many different prompts. Here you and the user are iterating on only a few examples over and over again because it helps move faster. The user knows these examples in and out and it's quick for them to assess new outputs. But if the skill you and the user are codeveloping works only for those examples, it's useless. Rather than put in fiddly overfitty changes, or oppressively constrictive MUSTs, if there's some stubborn issue, you might try branching out and using different metaphors, or recommending different patterns of working. It's relatively cheap to try and maybe you'll land on something great.
2. **Keep the prompt lean.** Remove things that aren't pulling their weight. Make sure to read the transcripts, not just the final outputs — if it looks like the skill is making the model waste a bunch of time doing things that are unproductive, you can try getting rid of the parts of the skill that are making it do that and seeing what happens.
3. **Explain the why.** Try hard to explain the **why** behind everything you're asking the model to do. Today's LLMs are *smart*. They have good theory of mind and when given a good harness can go beyond rote instructions and really make things happen. Even if the feedback from the user is terse or frustrated, try to actually understand the task and why the user is writing what they wrote, and what they actually wrote, and then transmit this understanding into the instructions. If you find yourself writing ALWAYS or NEVER in all caps, or using super rigid structures, that's a yellow flag — if possible, reframe and explain the reasoning so that the model understands why the thing you're asking for is important. That's a more humane, powerful, and effective approach.
4. **Look for repeated work across test cases.** Read the transcripts from the test runs and notice if the subagents all independently wrote similar helper scripts or took the same multi-step approach to something. If all 3 test cases resulted in the subagent writing a `create_docx.py` or a `build_chart.py`, that's a strong signal the skill should bundle that script. Write it once, put it in `scripts/`, and tell the skill to use it. This saves every future invocation from reinventing the wheel.
This task is pretty important (we are trying to create billions a year in economic value here!) and your thinking time is not the blocker; take your time and really mull things over. I'd suggest writing a draft revision and then looking at it anew and making improvements. Really do your best to get into the head of the user and understand what they want and need.
### The iteration loop
After improving the skill:
1. Apply your improvements to the skill
2. Rerun all test cases into a new `iteration-<N+1>/` directory, including baseline runs. If you're creating a new skill, the baseline is always `without_skill` (no skill) — that stays the same across iterations. If you're improving an existing skill, use your judgment on what makes sense as the baseline: the original version the user came in with, or the previous iteration.
3. Launch the reviewer with `--previous-workspace` pointing at the previous iteration
4. Wait for the user to review and tell you they're done
5. Read the new feedback, improve again, repeat
Keep going until:
- The user says they're happy
- The feedback is all empty (everything looks good)
- You're not making meaningful progress
---
## Advanced: Blind comparison
For situations where you want a more rigorous comparison between two versions of a skill (e.g., the user asks "is the new version actually better?"), there's a blind comparison system. Read `agents/comparator.md` and `agents/analyzer.md` for the details. The basic idea is: give two outputs to an independent agent without telling it which is which, and let it judge quality. Then analyze why the winner won.
This is optional, requires subagents, and most users won't need it. The human review loop is usually sufficient.
---
## Description Optimization
The description field in SKILL.md frontmatter is the primary mechanism that determines whether Claude invokes a skill. After creating or improving a skill, offer to optimize the description for better triggering accuracy.
### Step 1: Generate trigger eval queries
Create 20 eval queries — a mix of should-trigger and should-not-trigger. Save as JSON:
```json
[
{"query": "the user prompt", "should_trigger": true},
{"query": "another prompt", "should_trigger": false}
]
```
The queries must be realistic and something a Claude Code or Claude.ai user would actually type. Not abstract requests, but requests that are concrete and specific and have a good amount of detail. For instance, file paths, personal context about the user's job or situation, column names and values, company names, URLs. A little bit of backstory. Some might be in lowercase or contain abbreviations or typos or casual speech. Use a mix of different lengths, and focus on edge cases rather than making them clear-cut (the user will get a chance to sign off on them).
Bad: `"Format this data"`, `"Extract text from PDF"`, `"Create a chart"`
Good: `"ok so my boss just sent me this xlsx file (its in my downloads, called something like 'Q4 sales final FINAL v2.xlsx') and she wants me to add a column that shows the profit margin as a percentage. The revenue is in column C and costs are in column D i think"`
For the **should-trigger** queries (8-10), think about coverage. You want different phrasings of the same intent — some formal, some casual. Include cases where the user doesn't explicitly name the skill or file type but clearly needs it. Throw in some uncommon use cases and cases where this skill competes with another but should win.
For the **should-not-trigger** queries (8-10), the most valuable ones are the near-misses — queries that share keywords or concepts with the skill but actually need something different. Think adjacent domains, ambiguous phrasing where a naive keyword match would trigger but shouldn't, and cases where the query touches on something the skill does but in a context where another tool is more appropriate.
The key thing to avoid: don't make should-not-trigger queries obviously irrelevant. "Write a fibonacci function" as a negative test for a PDF skill is too easy — it doesn't test anything. The negative cases should be genuinely tricky.
### Step 2: Review with user
Present the eval set to the user for review using the HTML template:
1. Read the template from `assets/eval_review.html`
2. Replace the placeholders:
- `__EVAL_DATA_PLACEHOLDER__` → the JSON array of eval items (no quotes around it — it's a JS variable assignment)
- `__SKILL_NAME_PLACEHOLDER__` → the skill's name
- `__SKILL_DESCRIPTION_PLACEHOLDER__` → the skill's current description
3. Write to a temp file (e.g., `/tmp/eval_review_<skill-name>.html`) and open it: `open /tmp/eval_review_<skill-name>.html`
4. The user can edit queries, toggle should-trigger, add/remove entries, then click "Export Eval Set"
5. The file downloads to `~/Downloads/eval_set.json` — check the Downloads folder for the most recent version in case there are multiple (e.g., `eval_set (1).json`)
This step matters — bad eval queries lead to bad descriptions.
### Step 3: Run the optimization loop
Tell the user: "This will take some time — I'll run the optimization loop in the background and check on it periodically."
Save the eval set to the workspace, then run in the background:
```bash
python -m scripts.run_loop \
--eval-set <path-to-trigger-eval.json> \
--skill-path <path-to-skill> \
--model <model-id-powering-this-session> \
--max-iterations 5 \
--verbose
```
Use the model ID from your system prompt (the one powering the current session) so the triggering test matches what the user actually experiences.
While it runs, periodically tail the output to give the user updates on which iteration it's on and what the scores look like.
This handles the full optimization loop automatically. It splits the eval set into 60% train and 40% held-out test, evaluates the current description (running each query 3 times to get a reliable trigger rate), then calls Claude with extended thinking to propose improvements based on what failed. It re-evaluates each new description on both train and test, iterating up to 5 times. When it's done, it opens an HTML report in the browser showing the results per iteration and returns JSON with `best_description` — selected by test score rather than train score to avoid overfitting.
### How skill triggering works
Understanding the triggering mechanism helps design better eval queries. Skills appear in Claude's `available_skills` list with their name + description, and Claude decides whether to consult a skill based on that description. The important thing to know is that Claude only consults skills for tasks it can't easily handle on its own — simple, one-step queries like "read this PDF" may not trigger a skill even if the description matches perfectly, because Claude can handle them directly with basic tools. Complex, multi-step, or specialized queries reliably trigger skills when the description matches.
This means your eval queries should be substantive enough that Claude would actually benefit from consulting a skill. Simple queries like "read file X" are poor test cases — they won't trigger skills regardless of description quality.
### Step 4: Apply the result
Take `best_description` from the JSON output and update the skill's SKILL.md frontmatter. Show the user before/after and report the scores.
---
### Package and Present (only if `present_files` tool is available)
Check whether you have access to the `present_files` tool. If you don't, skip this step. If you do, package the skill and present the .skill file to the user:
```bash
python -m scripts.package_skill <path/to/skill-folder>
```
After packaging, direct the user to the resulting `.skill` file path so they can install it.
---
## Claude.ai-specific instructions
In Claude.ai, the core workflow is the same (draft → test → review → improve → repeat), but because Claude.ai doesn't have subagents, some mechanics change. Here's what to adapt:
**Running test cases**: No subagents means no parallel execution. For each test case, read the skill's SKILL.md, then follow its instructions to accomplish the test prompt yourself. Do them one at a time. This is less rigorous than independent subagents (you wrote the skill and you're also running it, so you have full context), but it's a useful sanity check — and the human review step compensates. Skip the baseline runs — just use the skill to complete the task as requested.
**Reviewing results**: If you can't open a browser (e.g., Claude.ai's VM has no display, or you're on a remote server), skip the browser reviewer entirely. Instead, present results directly in the conversation. For each test case, show the prompt and the output. If the output is a file the user needs to see (like a .docx or .xlsx), save it to the filesystem and tell them where it is so they can download and inspect it. Ask for feedback inline: "How does this look? Anything you'd change?"
**Benchmarking**: Skip the quantitative benchmarking — it relies on baseline comparisons which aren't meaningful without subagents. Focus on qualitative feedback from the user.
**The iteration loop**: Same as before — improve the skill, rerun the test cases, ask for feedback — just without the browser reviewer in the middle. You can still organize results into iteration directories on the filesystem if you have one.
**Description optimization**: This section requires the `claude` CLI tool (specifically `claude -p`) which is only available in Claude Code. Skip it if you're on Claude.ai.
**Blind comparison**: Requires subagents. Skip it.
**Packaging**: The `package_skill.py` script works anywhere with Python and a filesystem. On Claude.ai, you can run it and the user can download the resulting `.skill` file.
---
## Cowork-Specific Instructions
If you're in Cowork, the main things to know are:
- You have subagents, so the main workflow (spawn test cases in parallel, run baselines, grade, etc.) all works. (However, if you run into severe problems with timeouts, it's OK to run the test prompts in series rather than parallel.)
- You don't have a browser or display, so when generating the eval viewer, use `--static <output_path>` to write a standalone HTML file instead of starting a server. Then proffer a link that the user can click to open the HTML in their browser.
- For whatever reason, the Cowork setup seems to disincline Claude from generating the eval viewer after running the tests, so just to reiterate: whether you're in Cowork or in Claude Code, after running tests, you should always generate the eval viewer for the human to look at examples before revising the skill yourself and trying to make corrections, using `generate_review.py` (not writing your own boutique html code). Sorry in advance but I'm gonna go all caps here: GENERATE THE EVAL VIEWER *BEFORE* evaluating inputs yourself. You want to get them in front of the human ASAP!
- Feedback works differently: since there's no running server, the viewer's "Submit All Reviews" button will download `feedback.json` as a file. You can then read it from there (you may have to request access first).
- Packaging works — `package_skill.py` just needs Python and a filesystem.
- Description optimization (`run_loop.py` / `run_eval.py`) should work in Cowork just fine since it uses `claude -p` via subprocess, not a browser, but please save it until you've fully finished making the skill and the user agrees it's in good shape.
---
## Reference files
The agents/ directory contains instructions for specialized subagents. Read them when you need to spawn the relevant subagent.
- `agents/grader.md` — How to evaluate assertions against outputs
- `agents/comparator.md` — How to do blind A/B comparison between two outputs
- `agents/analyzer.md` — How to analyze why one version beat another
The references/ directory has additional documentation:
- `references/schemas.md` — JSON structures for evals.json, grading.json, etc.
---
Repeating one more time the core loop here for emphasis:
- Figure out what the skill is about
- Draft or edit the skill
- Run claude-with-access-to-the-skill on test prompts
- With the user, evaluate the outputs:
- Create benchmark.json and run `eval-viewer/generate_review.py` to help the user review them
- Run quantitative evals
- Repeat until you and the user are satisfied
- Package the final skill and return it to the user.
Please add steps to your TodoList, if you have such a thing, to make sure you don't forget. If you're in Cowork, please specifically put "Create evals JSON and run `eval-viewer/generate_review.py` so human can review test cases" in your TodoList to make sure it happens.
Good luck!

View file

@ -0,0 +1,274 @@
# Post-hoc Analyzer Agent
Analyze blind comparison results to understand WHY the winner won and generate improvement suggestions.
## Role
After the blind comparator determines a winner, the Post-hoc Analyzer "unblids" the results by examining the skills and transcripts. The goal is to extract actionable insights: what made the winner better, and how can the loser be improved?
## Inputs
You receive these parameters in your prompt:
- **winner**: "A" or "B" (from blind comparison)
- **winner_skill_path**: Path to the skill that produced the winning output
- **winner_transcript_path**: Path to the execution transcript for the winner
- **loser_skill_path**: Path to the skill that produced the losing output
- **loser_transcript_path**: Path to the execution transcript for the loser
- **comparison_result_path**: Path to the blind comparator's output JSON
- **output_path**: Where to save the analysis results
## Process
### Step 1: Read Comparison Result
1. Read the blind comparator's output at comparison_result_path
2. Note the winning side (A or B), the reasoning, and any scores
3. Understand what the comparator valued in the winning output
### Step 2: Read Both Skills
1. Read the winner skill's SKILL.md and key referenced files
2. Read the loser skill's SKILL.md and key referenced files
3. Identify structural differences:
- Instructions clarity and specificity
- Script/tool usage patterns
- Example coverage
- Edge case handling
### Step 3: Read Both Transcripts
1. Read the winner's transcript
2. Read the loser's transcript
3. Compare execution patterns:
- How closely did each follow their skill's instructions?
- What tools were used differently?
- Where did the loser diverge from optimal behavior?
- Did either encounter errors or make recovery attempts?
### Step 4: Analyze Instruction Following
For each transcript, evaluate:
- Did the agent follow the skill's explicit instructions?
- Did the agent use the skill's provided tools/scripts?
- Were there missed opportunities to leverage skill content?
- Did the agent add unnecessary steps not in the skill?
Score instruction following 1-10 and note specific issues.
### Step 5: Identify Winner Strengths
Determine what made the winner better:
- Clearer instructions that led to better behavior?
- Better scripts/tools that produced better output?
- More comprehensive examples that guided edge cases?
- Better error handling guidance?
Be specific. Quote from skills/transcripts where relevant.
### Step 6: Identify Loser Weaknesses
Determine what held the loser back:
- Ambiguous instructions that led to suboptimal choices?
- Missing tools/scripts that forced workarounds?
- Gaps in edge case coverage?
- Poor error handling that caused failures?
### Step 7: Generate Improvement Suggestions
Based on the analysis, produce actionable suggestions for improving the loser skill:
- Specific instruction changes to make
- Tools/scripts to add or modify
- Examples to include
- Edge cases to address
Prioritize by impact. Focus on changes that would have changed the outcome.
### Step 8: Write Analysis Results
Save structured analysis to `{output_path}`.
## Output Format
Write a JSON file with this structure:
```json
{
"comparison_summary": {
"winner": "A",
"winner_skill": "path/to/winner/skill",
"loser_skill": "path/to/loser/skill",
"comparator_reasoning": "Brief summary of why comparator chose winner"
},
"winner_strengths": [
"Clear step-by-step instructions for handling multi-page documents",
"Included validation script that caught formatting errors",
"Explicit guidance on fallback behavior when OCR fails"
],
"loser_weaknesses": [
"Vague instruction 'process the document appropriately' led to inconsistent behavior",
"No script for validation, agent had to improvise and made errors",
"No guidance on OCR failure, agent gave up instead of trying alternatives"
],
"instruction_following": {
"winner": {
"score": 9,
"issues": [
"Minor: skipped optional logging step"
]
},
"loser": {
"score": 6,
"issues": [
"Did not use the skill's formatting template",
"Invented own approach instead of following step 3",
"Missed the 'always validate output' instruction"
]
}
},
"improvement_suggestions": [
{
"priority": "high",
"category": "instructions",
"suggestion": "Replace 'process the document appropriately' with explicit steps: 1) Extract text, 2) Identify sections, 3) Format per template",
"expected_impact": "Would eliminate ambiguity that caused inconsistent behavior"
},
{
"priority": "high",
"category": "tools",
"suggestion": "Add validate_output.py script similar to winner skill's validation approach",
"expected_impact": "Would catch formatting errors before final output"
},
{
"priority": "medium",
"category": "error_handling",
"suggestion": "Add fallback instructions: 'If OCR fails, try: 1) different resolution, 2) image preprocessing, 3) manual extraction'",
"expected_impact": "Would prevent early failure on difficult documents"
}
],
"transcript_insights": {
"winner_execution_pattern": "Read skill -> Followed 5-step process -> Used validation script -> Fixed 2 issues -> Produced output",
"loser_execution_pattern": "Read skill -> Unclear on approach -> Tried 3 different methods -> No validation -> Output had errors"
}
}
```
## Guidelines
- **Be specific**: Quote from skills and transcripts, don't just say "instructions were unclear"
- **Be actionable**: Suggestions should be concrete changes, not vague advice
- **Focus on skill improvements**: The goal is to improve the losing skill, not critique the agent
- **Prioritize by impact**: Which changes would most likely have changed the outcome?
- **Consider causation**: Did the skill weakness actually cause the worse output, or is it incidental?
- **Stay objective**: Analyze what happened, don't editorialize
- **Think about generalization**: Would this improvement help on other evals too?
## Categories for Suggestions
Use these categories to organize improvement suggestions:
| Category | Description |
|----------|-------------|
| `instructions` | Changes to the skill's prose instructions |
| `tools` | Scripts, templates, or utilities to add/modify |
| `examples` | Example inputs/outputs to include |
| `error_handling` | Guidance for handling failures |
| `structure` | Reorganization of skill content |
| `references` | External docs or resources to add |
## Priority Levels
- **high**: Would likely change the outcome of this comparison
- **medium**: Would improve quality but may not change win/loss
- **low**: Nice to have, marginal improvement
---
# Analyzing Benchmark Results
When analyzing benchmark results, the analyzer's purpose is to **surface patterns and anomalies** across multiple runs, not suggest skill improvements.
## Role
Review all benchmark run results and generate freeform notes that help the user understand skill performance. Focus on patterns that wouldn't be visible from aggregate metrics alone.
## Inputs
You receive these parameters in your prompt:
- **benchmark_data_path**: Path to the in-progress benchmark.json with all run results
- **skill_path**: Path to the skill being benchmarked
- **output_path**: Where to save the notes (as JSON array of strings)
## Process
### Step 1: Read Benchmark Data
1. Read the benchmark.json containing all run results
2. Note the configurations tested (with_skill, without_skill)
3. Understand the run_summary aggregates already calculated
### Step 2: Analyze Per-Assertion Patterns
For each expectation across all runs:
- Does it **always pass** in both configurations? (may not differentiate skill value)
- Does it **always fail** in both configurations? (may be broken or beyond capability)
- Does it **always pass with skill but fail without**? (skill clearly adds value here)
- Does it **always fail with skill but pass without**? (skill may be hurting)
- Is it **highly variable**? (flaky expectation or non-deterministic behavior)
### Step 3: Analyze Cross-Eval Patterns
Look for patterns across evals:
- Are certain eval types consistently harder/easier?
- Do some evals show high variance while others are stable?
- Are there surprising results that contradict expectations?
### Step 4: Analyze Metrics Patterns
Look at time_seconds, tokens, tool_calls:
- Does the skill significantly increase execution time?
- Is there high variance in resource usage?
- Are there outlier runs that skew the aggregates?
### Step 5: Generate Notes
Write freeform observations as a list of strings. Each note should:
- State a specific observation
- Be grounded in the data (not speculation)
- Help the user understand something the aggregate metrics don't show
Examples:
- "Assertion 'Output is a PDF file' passes 100% in both configurations - may not differentiate skill value"
- "Eval 3 shows high variance (50% ± 40%) - run 2 had an unusual failure that may be flaky"
- "Without-skill runs consistently fail on table extraction expectations (0% pass rate)"
- "Skill adds 13s average execution time but improves pass rate by 50%"
- "Token usage is 80% higher with skill, primarily due to script output parsing"
- "All 3 without-skill runs for eval 1 produced empty output"
### Step 6: Write Notes
Save notes to `{output_path}` as a JSON array of strings:
```json
[
"Assertion 'Output is a PDF file' passes 100% in both configurations - may not differentiate skill value",
"Eval 3 shows high variance (50% ± 40%) - run 2 had an unusual failure",
"Without-skill runs consistently fail on table extraction expectations",
"Skill adds 13s average execution time but improves pass rate by 50%"
]
```
## Guidelines
**DO:**
- Report what you observe in the data
- Be specific about which evals, expectations, or runs you're referring to
- Note patterns that aggregate metrics would hide
- Provide context that helps interpret the numbers
**DO NOT:**
- Suggest improvements to the skill (that's for the improvement step, not benchmarking)
- Make subjective quality judgments ("the output was good/bad")
- Speculate about causes without evidence
- Repeat information already in the run_summary aggregates

View file

@ -0,0 +1,202 @@
# Blind Comparator Agent
Compare two outputs WITHOUT knowing which skill produced them.
## Role
The Blind Comparator judges which output better accomplishes the eval task. You receive two outputs labeled A and B, but you do NOT know which skill produced which. This prevents bias toward a particular skill or approach.
Your judgment is based purely on output quality and task completion.
## Inputs
You receive these parameters in your prompt:
- **output_a_path**: Path to the first output file or directory
- **output_b_path**: Path to the second output file or directory
- **eval_prompt**: The original task/prompt that was executed
- **expectations**: List of expectations to check (optional - may be empty)
## Process
### Step 1: Read Both Outputs
1. Examine output A (file or directory)
2. Examine output B (file or directory)
3. Note the type, structure, and content of each
4. If outputs are directories, examine all relevant files inside
### Step 2: Understand the Task
1. Read the eval_prompt carefully
2. Identify what the task requires:
- What should be produced?
- What qualities matter (accuracy, completeness, format)?
- What would distinguish a good output from a poor one?
### Step 3: Generate Evaluation Rubric
Based on the task, generate a rubric with two dimensions:
**Content Rubric** (what the output contains):
| Criterion | 1 (Poor) | 3 (Acceptable) | 5 (Excellent) |
|-----------|----------|----------------|---------------|
| Correctness | Major errors | Minor errors | Fully correct |
| Completeness | Missing key elements | Mostly complete | All elements present |
| Accuracy | Significant inaccuracies | Minor inaccuracies | Accurate throughout |
**Structure Rubric** (how the output is organized):
| Criterion | 1 (Poor) | 3 (Acceptable) | 5 (Excellent) |
|-----------|----------|----------------|---------------|
| Organization | Disorganized | Reasonably organized | Clear, logical structure |
| Formatting | Inconsistent/broken | Mostly consistent | Professional, polished |
| Usability | Difficult to use | Usable with effort | Easy to use |
Adapt criteria to the specific task. For example:
- PDF form → "Field alignment", "Text readability", "Data placement"
- Document → "Section structure", "Heading hierarchy", "Paragraph flow"
- Data output → "Schema correctness", "Data types", "Completeness"
### Step 4: Evaluate Each Output Against the Rubric
For each output (A and B):
1. **Score each criterion** on the rubric (1-5 scale)
2. **Calculate dimension totals**: Content score, Structure score
3. **Calculate overall score**: Average of dimension scores, scaled to 1-10
### Step 5: Check Assertions (if provided)
If expectations are provided:
1. Check each expectation against output A
2. Check each expectation against output B
3. Count pass rates for each output
4. Use expectation scores as secondary evidence (not the primary decision factor)
### Step 6: Determine the Winner
Compare A and B based on (in priority order):
1. **Primary**: Overall rubric score (content + structure)
2. **Secondary**: Assertion pass rates (if applicable)
3. **Tiebreaker**: If truly equal, declare a TIE
Be decisive - ties should be rare. One output is usually better, even if marginally.
### Step 7: Write Comparison Results
Save results to a JSON file at the path specified (or `comparison.json` if not specified).
## Output Format
Write a JSON file with this structure:
```json
{
"winner": "A",
"reasoning": "Output A provides a complete solution with proper formatting and all required fields. Output B is missing the date field and has formatting inconsistencies.",
"rubric": {
"A": {
"content": {
"correctness": 5,
"completeness": 5,
"accuracy": 4
},
"structure": {
"organization": 4,
"formatting": 5,
"usability": 4
},
"content_score": 4.7,
"structure_score": 4.3,
"overall_score": 9.0
},
"B": {
"content": {
"correctness": 3,
"completeness": 2,
"accuracy": 3
},
"structure": {
"organization": 3,
"formatting": 2,
"usability": 3
},
"content_score": 2.7,
"structure_score": 2.7,
"overall_score": 5.4
}
},
"output_quality": {
"A": {
"score": 9,
"strengths": ["Complete solution", "Well-formatted", "All fields present"],
"weaknesses": ["Minor style inconsistency in header"]
},
"B": {
"score": 5,
"strengths": ["Readable output", "Correct basic structure"],
"weaknesses": ["Missing date field", "Formatting inconsistencies", "Partial data extraction"]
}
},
"expectation_results": {
"A": {
"passed": 4,
"total": 5,
"pass_rate": 0.80,
"details": [
{"text": "Output includes name", "passed": true},
{"text": "Output includes date", "passed": true},
{"text": "Format is PDF", "passed": true},
{"text": "Contains signature", "passed": false},
{"text": "Readable text", "passed": true}
]
},
"B": {
"passed": 3,
"total": 5,
"pass_rate": 0.60,
"details": [
{"text": "Output includes name", "passed": true},
{"text": "Output includes date", "passed": false},
{"text": "Format is PDF", "passed": true},
{"text": "Contains signature", "passed": false},
{"text": "Readable text", "passed": true}
]
}
}
}
```
If no expectations were provided, omit the `expectation_results` field entirely.
## Field Descriptions
- **winner**: "A", "B", or "TIE"
- **reasoning**: Clear explanation of why the winner was chosen (or why it's a tie)
- **rubric**: Structured rubric evaluation for each output
- **content**: Scores for content criteria (correctness, completeness, accuracy)
- **structure**: Scores for structure criteria (organization, formatting, usability)
- **content_score**: Average of content criteria (1-5)
- **structure_score**: Average of structure criteria (1-5)
- **overall_score**: Combined score scaled to 1-10
- **output_quality**: Summary quality assessment
- **score**: 1-10 rating (should match rubric overall_score)
- **strengths**: List of positive aspects
- **weaknesses**: List of issues or shortcomings
- **expectation_results**: (Only if expectations provided)
- **passed**: Number of expectations that passed
- **total**: Total number of expectations
- **pass_rate**: Fraction passed (0.0 to 1.0)
- **details**: Individual expectation results
## Guidelines
- **Stay blind**: DO NOT try to infer which skill produced which output. Judge purely on output quality.
- **Be specific**: Cite specific examples when explaining strengths and weaknesses.
- **Be decisive**: Choose a winner unless outputs are genuinely equivalent.
- **Output quality first**: Assertion scores are secondary to overall task completion.
- **Be objective**: Don't favor outputs based on style preferences; focus on correctness and completeness.
- **Explain your reasoning**: The reasoning field should make it clear why you chose the winner.
- **Handle edge cases**: If both outputs fail, pick the one that fails less badly. If both are excellent, pick the one that's marginally better.

View file

@ -0,0 +1,223 @@
# Grader Agent
Evaluate expectations against an execution transcript and outputs.
## Role
The Grader reviews a transcript and output files, then determines whether each expectation passes or fails. Provide clear evidence for each judgment.
You have two jobs: grade the outputs, and critique the evals themselves. A passing grade on a weak assertion is worse than useless — it creates false confidence. When you notice an assertion that's trivially satisfied, or an important outcome that no assertion checks, say so.
## Inputs
You receive these parameters in your prompt:
- **expectations**: List of expectations to evaluate (strings)
- **transcript_path**: Path to the execution transcript (markdown file)
- **outputs_dir**: Directory containing output files from execution
## Process
### Step 1: Read the Transcript
1. Read the transcript file completely
2. Note the eval prompt, execution steps, and final result
3. Identify any issues or errors documented
### Step 2: Examine Output Files
1. List files in outputs_dir
2. Read/examine each file relevant to the expectations. If outputs aren't plain text, use the inspection tools provided in your prompt — don't rely solely on what the transcript says the executor produced.
3. Note contents, structure, and quality
### Step 3: Evaluate Each Assertion
For each expectation:
1. **Search for evidence** in the transcript and outputs
2. **Determine verdict**:
- **PASS**: Clear evidence the expectation is true AND the evidence reflects genuine task completion, not just surface-level compliance
- **FAIL**: No evidence, or evidence contradicts the expectation, or the evidence is superficial (e.g., correct filename but empty/wrong content)
3. **Cite the evidence**: Quote the specific text or describe what you found
### Step 4: Extract and Verify Claims
Beyond the predefined expectations, extract implicit claims from the outputs and verify them:
1. **Extract claims** from the transcript and outputs:
- Factual statements ("The form has 12 fields")
- Process claims ("Used pypdf to fill the form")
- Quality claims ("All fields were filled correctly")
2. **Verify each claim**:
- **Factual claims**: Can be checked against the outputs or external sources
- **Process claims**: Can be verified from the transcript
- **Quality claims**: Evaluate whether the claim is justified
3. **Flag unverifiable claims**: Note claims that cannot be verified with available information
This catches issues that predefined expectations might miss.
### Step 5: Read User Notes
If `{outputs_dir}/user_notes.md` exists:
1. Read it and note any uncertainties or issues flagged by the executor
2. Include relevant concerns in the grading output
3. These may reveal problems even when expectations pass
### Step 6: Critique the Evals
After grading, consider whether the evals themselves could be improved. Only surface suggestions when there's a clear gap.
Good suggestions test meaningful outcomes — assertions that are hard to satisfy without actually doing the work correctly. Think about what makes an assertion *discriminating*: it passes when the skill genuinely succeeds and fails when it doesn't.
Suggestions worth raising:
- An assertion that passed but would also pass for a clearly wrong output (e.g., checking filename existence but not file content)
- An important outcome you observed — good or bad — that no assertion covers at all
- An assertion that can't actually be verified from the available outputs
Keep the bar high. The goal is to flag things the eval author would say "good catch" about, not to nitpick every assertion.
### Step 7: Write Grading Results
Save results to `{outputs_dir}/../grading.json` (sibling to outputs_dir).
## Grading Criteria
**PASS when**:
- The transcript or outputs clearly demonstrate the expectation is true
- Specific evidence can be cited
- The evidence reflects genuine substance, not just surface compliance (e.g., a file exists AND contains correct content, not just the right filename)
**FAIL when**:
- No evidence found for the expectation
- Evidence contradicts the expectation
- The expectation cannot be verified from available information
- The evidence is superficial — the assertion is technically satisfied but the underlying task outcome is wrong or incomplete
- The output appears to meet the assertion by coincidence rather than by actually doing the work
**When uncertain**: The burden of proof to pass is on the expectation.
### Step 8: Read Executor Metrics and Timing
1. If `{outputs_dir}/metrics.json` exists, read it and include in grading output
2. If `{outputs_dir}/../timing.json` exists, read it and include timing data
## Output Format
Write a JSON file with this structure:
```json
{
"expectations": [
{
"text": "The output includes the name 'John Smith'",
"passed": true,
"evidence": "Found in transcript Step 3: 'Extracted names: John Smith, Sarah Johnson'"
},
{
"text": "The spreadsheet has a SUM formula in cell B10",
"passed": false,
"evidence": "No spreadsheet was created. The output was a text file."
},
{
"text": "The assistant used the skill's OCR script",
"passed": true,
"evidence": "Transcript Step 2 shows: 'Tool: Bash - python ocr_script.py image.png'"
}
],
"summary": {
"passed": 2,
"failed": 1,
"total": 3,
"pass_rate": 0.67
},
"execution_metrics": {
"tool_calls": {
"Read": 5,
"Write": 2,
"Bash": 8
},
"total_tool_calls": 15,
"total_steps": 6,
"errors_encountered": 0,
"output_chars": 12450,
"transcript_chars": 3200
},
"timing": {
"executor_duration_seconds": 165.0,
"grader_duration_seconds": 26.0,
"total_duration_seconds": 191.0
},
"claims": [
{
"claim": "The form has 12 fillable fields",
"type": "factual",
"verified": true,
"evidence": "Counted 12 fields in field_info.json"
},
{
"claim": "All required fields were populated",
"type": "quality",
"verified": false,
"evidence": "Reference section was left blank despite data being available"
}
],
"user_notes_summary": {
"uncertainties": ["Used 2023 data, may be stale"],
"needs_review": [],
"workarounds": ["Fell back to text overlay for non-fillable fields"]
},
"eval_feedback": {
"suggestions": [
{
"assertion": "The output includes the name 'John Smith'",
"reason": "A hallucinated document that mentions the name would also pass — consider checking it appears as the primary contact with matching phone and email from the input"
},
{
"reason": "No assertion checks whether the extracted phone numbers match the input — I observed incorrect numbers in the output that went uncaught"
}
],
"overall": "Assertions check presence but not correctness. Consider adding content verification."
}
}
```
## Field Descriptions
- **expectations**: Array of graded expectations
- **text**: The original expectation text
- **passed**: Boolean - true if expectation passes
- **evidence**: Specific quote or description supporting the verdict
- **summary**: Aggregate statistics
- **passed**: Count of passed expectations
- **failed**: Count of failed expectations
- **total**: Total expectations evaluated
- **pass_rate**: Fraction passed (0.0 to 1.0)
- **execution_metrics**: Copied from executor's metrics.json (if available)
- **output_chars**: Total character count of output files (proxy for tokens)
- **transcript_chars**: Character count of transcript
- **timing**: Wall clock timing from timing.json (if available)
- **executor_duration_seconds**: Time spent in executor subagent
- **total_duration_seconds**: Total elapsed time for the run
- **claims**: Extracted and verified claims from the output
- **claim**: The statement being verified
- **type**: "factual", "process", or "quality"
- **verified**: Boolean - whether the claim holds
- **evidence**: Supporting or contradicting evidence
- **user_notes_summary**: Issues flagged by the executor
- **uncertainties**: Things the executor wasn't sure about
- **needs_review**: Items requiring human attention
- **workarounds**: Places where the skill didn't work as expected
- **eval_feedback**: Improvement suggestions for the evals (only when warranted)
- **suggestions**: List of concrete suggestions, each with a `reason` and optionally an `assertion` it relates to
- **overall**: Brief assessment — can be "No suggestions, evals look solid" if nothing to flag
## Guidelines
- **Be objective**: Base verdicts on evidence, not assumptions
- **Be specific**: Quote the exact text that supports your verdict
- **Be thorough**: Check both transcript and output files
- **Be consistent**: Apply the same standard to each expectation
- **Explain failures**: Make it clear why evidence was insufficient
- **No partial credit**: Each expectation is pass or fail, not partial

View file

@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eval Set Review - __SKILL_NAME_PLACEHOLDER__</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@500;600&family=Lora:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Lora', Georgia, serif; background: #faf9f5; padding: 2rem; color: #141413; }
h1 { font-family: 'Poppins', sans-serif; margin-bottom: 0.5rem; font-size: 1.5rem; }
.description { color: #b0aea5; margin-bottom: 1.5rem; font-style: italic; max-width: 900px; }
.controls { margin-bottom: 1rem; display: flex; gap: 0.5rem; }
.btn { font-family: 'Poppins', sans-serif; padding: 0.5rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; }
.btn-add { background: #6a9bcc; color: white; }
.btn-add:hover { background: #5889b8; }
.btn-export { background: #d97757; color: white; }
.btn-export:hover { background: #c4613f; }
table { width: 100%; max-width: 1100px; border-collapse: collapse; background: white; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
th { font-family: 'Poppins', sans-serif; background: #141413; color: #faf9f5; padding: 0.75rem 1rem; text-align: left; font-size: 0.875rem; }
td { padding: 0.75rem 1rem; border-bottom: 1px solid #e8e6dc; vertical-align: top; }
tr:nth-child(even) td { background: #faf9f5; }
tr:hover td { background: #f3f1ea; }
.section-header td { background: #e8e6dc; font-family: 'Poppins', sans-serif; font-weight: 500; font-size: 0.8rem; color: #141413; text-transform: uppercase; letter-spacing: 0.05em; }
.query-input { width: 100%; padding: 0.4rem; border: 1px solid #e8e6dc; border-radius: 4px; font-size: 0.875rem; font-family: 'Lora', Georgia, serif; resize: vertical; min-height: 60px; }
.query-input:focus { outline: none; border-color: #d97757; box-shadow: 0 0 0 2px rgba(217,119,87,0.15); }
.toggle { position: relative; display: inline-block; width: 44px; height: 24px; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle .slider { position: absolute; inset: 0; background: #b0aea5; border-radius: 24px; cursor: pointer; transition: 0.2s; }
.toggle .slider::before { content: ""; position: absolute; width: 18px; height: 18px; left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: 0.2s; }
.toggle input:checked + .slider { background: #d97757; }
.toggle input:checked + .slider::before { transform: translateX(20px); }
.btn-delete { background: #c44; color: white; padding: 0.3rem 0.6rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.75rem; font-family: 'Poppins', sans-serif; }
.btn-delete:hover { background: #a33; }
.summary { margin-top: 1rem; color: #b0aea5; font-size: 0.875rem; }
</style>
</head>
<body>
<h1>Eval Set Review: <span id="skill-name">__SKILL_NAME_PLACEHOLDER__</span></h1>
<p class="description">Current description: <span id="skill-desc">__SKILL_DESCRIPTION_PLACEHOLDER__</span></p>
<div class="controls">
<button class="btn btn-add" onclick="addRow()">+ Add Query</button>
<button class="btn btn-export" onclick="exportEvalSet()">Export Eval Set</button>
</div>
<table>
<thead>
<tr>
<th style="width:65%">Query</th>
<th style="width:18%">Should Trigger</th>
<th style="width:10%">Actions</th>
</tr>
</thead>
<tbody id="eval-body"></tbody>
</table>
<p class="summary" id="summary"></p>
<script>
const EVAL_DATA = __EVAL_DATA_PLACEHOLDER__;
let evalItems = [...EVAL_DATA];
function render() {
const tbody = document.getElementById('eval-body');
tbody.innerHTML = '';
// Sort: should-trigger first, then should-not-trigger
const sorted = evalItems
.map((item, origIdx) => ({ ...item, origIdx }))
.sort((a, b) => (b.should_trigger ? 1 : 0) - (a.should_trigger ? 1 : 0));
let lastGroup = null;
sorted.forEach(item => {
const group = item.should_trigger ? 'trigger' : 'no-trigger';
if (group !== lastGroup) {
const headerRow = document.createElement('tr');
headerRow.className = 'section-header';
headerRow.innerHTML = `<td colspan="3">${item.should_trigger ? 'Should Trigger' : 'Should NOT Trigger'}</td>`;
tbody.appendChild(headerRow);
lastGroup = group;
}
const idx = item.origIdx;
const tr = document.createElement('tr');
tr.innerHTML = `
<td><textarea class="query-input" onchange="updateQuery(${idx}, this.value)">${escapeHtml(item.query)}</textarea></td>
<td>
<label class="toggle">
<input type="checkbox" ${item.should_trigger ? 'checked' : ''} onchange="updateTrigger(${idx}, this.checked)">
<span class="slider"></span>
</label>
<span style="margin-left:8px;font-size:0.8rem;color:#b0aea5">${item.should_trigger ? 'Yes' : 'No'}</span>
</td>
<td><button class="btn-delete" onclick="deleteRow(${idx})">Delete</button></td>
`;
tbody.appendChild(tr);
});
updateSummary();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function updateQuery(idx, value) { evalItems[idx].query = value; updateSummary(); }
function updateTrigger(idx, value) { evalItems[idx].should_trigger = value; render(); }
function deleteRow(idx) { evalItems.splice(idx, 1); render(); }
function addRow() {
evalItems.push({ query: '', should_trigger: true });
render();
const inputs = document.querySelectorAll('.query-input');
inputs[inputs.length - 1].focus();
}
function updateSummary() {
const trigger = evalItems.filter(i => i.should_trigger).length;
const noTrigger = evalItems.filter(i => !i.should_trigger).length;
document.getElementById('summary').textContent =
`${evalItems.length} queries total: ${trigger} should trigger, ${noTrigger} should not trigger`;
}
function exportEvalSet() {
const valid = evalItems.filter(i => i.query.trim() !== '');
const data = valid.map(i => ({ query: i.query.trim(), should_trigger: i.should_trigger }));
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'eval_set.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
render();
</script>
</body>
</html>

View file

@ -0,0 +1,471 @@
#!/usr/bin/env python3
"""Generate and serve a review page for eval results.
Reads the workspace directory, discovers runs (directories with outputs/),
embeds all output data into a self-contained HTML page, and serves it via
a tiny HTTP server. Feedback auto-saves to feedback.json in the workspace.
Usage:
python generate_review.py <workspace-path> [--port PORT] [--skill-name NAME]
python generate_review.py <workspace-path> --previous-feedback /path/to/old/feedback.json
No dependencies beyond the Python stdlib are required.
"""
import argparse
import base64
import json
import mimetypes
import os
import re
import signal
import subprocess
import sys
import time
import webbrowser
from functools import partial
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
# Files to exclude from output listings
METADATA_FILES = {"transcript.md", "user_notes.md", "metrics.json"}
# Extensions we render as inline text
TEXT_EXTENSIONS = {
".txt", ".md", ".json", ".csv", ".py", ".js", ".ts", ".tsx", ".jsx",
".yaml", ".yml", ".xml", ".html", ".css", ".sh", ".rb", ".go", ".rs",
".java", ".c", ".cpp", ".h", ".hpp", ".sql", ".r", ".toml",
}
# Extensions we render as inline images
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
# MIME type overrides for common types
MIME_OVERRIDES = {
".svg": "image/svg+xml",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
}
def get_mime_type(path: Path) -> str:
ext = path.suffix.lower()
if ext in MIME_OVERRIDES:
return MIME_OVERRIDES[ext]
mime, _ = mimetypes.guess_type(str(path))
return mime or "application/octet-stream"
def find_runs(workspace: Path) -> list[dict]:
"""Recursively find directories that contain an outputs/ subdirectory."""
runs: list[dict] = []
_find_runs_recursive(workspace, workspace, runs)
runs.sort(key=lambda r: (r.get("eval_id", float("inf")), r["id"]))
return runs
def _find_runs_recursive(root: Path, current: Path, runs: list[dict]) -> None:
if not current.is_dir():
return
outputs_dir = current / "outputs"
if outputs_dir.is_dir():
run = build_run(root, current)
if run:
runs.append(run)
return
skip = {"node_modules", ".git", "__pycache__", "skill", "inputs"}
for child in sorted(current.iterdir()):
if child.is_dir() and child.name not in skip:
_find_runs_recursive(root, child, runs)
def build_run(root: Path, run_dir: Path) -> dict | None:
"""Build a run dict with prompt, outputs, and grading data."""
prompt = ""
eval_id = None
# Try eval_metadata.json
for candidate in [run_dir / "eval_metadata.json", run_dir.parent / "eval_metadata.json"]:
if candidate.exists():
try:
metadata = json.loads(candidate.read_text())
prompt = metadata.get("prompt", "")
eval_id = metadata.get("eval_id")
except (json.JSONDecodeError, OSError):
pass
if prompt:
break
# Fall back to transcript.md
if not prompt:
for candidate in [run_dir / "transcript.md", run_dir / "outputs" / "transcript.md"]:
if candidate.exists():
try:
text = candidate.read_text()
match = re.search(r"## Eval Prompt\n\n([\s\S]*?)(?=\n##|$)", text)
if match:
prompt = match.group(1).strip()
except OSError:
pass
if prompt:
break
if not prompt:
prompt = "(No prompt found)"
run_id = str(run_dir.relative_to(root)).replace("/", "-").replace("\\", "-")
# Collect output files
outputs_dir = run_dir / "outputs"
output_files: list[dict] = []
if outputs_dir.is_dir():
for f in sorted(outputs_dir.iterdir()):
if f.is_file() and f.name not in METADATA_FILES:
output_files.append(embed_file(f))
# Load grading if present
grading = None
for candidate in [run_dir / "grading.json", run_dir.parent / "grading.json"]:
if candidate.exists():
try:
grading = json.loads(candidate.read_text())
except (json.JSONDecodeError, OSError):
pass
if grading:
break
return {
"id": run_id,
"prompt": prompt,
"eval_id": eval_id,
"outputs": output_files,
"grading": grading,
}
def embed_file(path: Path) -> dict:
"""Read a file and return an embedded representation."""
ext = path.suffix.lower()
mime = get_mime_type(path)
if ext in TEXT_EXTENSIONS:
try:
content = path.read_text(errors="replace")
except OSError:
content = "(Error reading file)"
return {
"name": path.name,
"type": "text",
"content": content,
}
elif ext in IMAGE_EXTENSIONS:
try:
raw = path.read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
except OSError:
return {"name": path.name, "type": "error", "content": "(Error reading file)"}
return {
"name": path.name,
"type": "image",
"mime": mime,
"data_uri": f"data:{mime};base64,{b64}",
}
elif ext == ".pdf":
try:
raw = path.read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
except OSError:
return {"name": path.name, "type": "error", "content": "(Error reading file)"}
return {
"name": path.name,
"type": "pdf",
"data_uri": f"data:{mime};base64,{b64}",
}
elif ext == ".xlsx":
try:
raw = path.read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
except OSError:
return {"name": path.name, "type": "error", "content": "(Error reading file)"}
return {
"name": path.name,
"type": "xlsx",
"data_b64": b64,
}
else:
# Binary / unknown — base64 download link
try:
raw = path.read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
except OSError:
return {"name": path.name, "type": "error", "content": "(Error reading file)"}
return {
"name": path.name,
"type": "binary",
"mime": mime,
"data_uri": f"data:{mime};base64,{b64}",
}
def load_previous_iteration(workspace: Path) -> dict[str, dict]:
"""Load previous iteration's feedback and outputs.
Returns a map of run_id -> {"feedback": str, "outputs": list[dict]}.
"""
result: dict[str, dict] = {}
# Load feedback
feedback_map: dict[str, str] = {}
feedback_path = workspace / "feedback.json"
if feedback_path.exists():
try:
data = json.loads(feedback_path.read_text())
feedback_map = {
r["run_id"]: r["feedback"]
for r in data.get("reviews", [])
if r.get("feedback", "").strip()
}
except (json.JSONDecodeError, OSError, KeyError):
pass
# Load runs (to get outputs)
prev_runs = find_runs(workspace)
for run in prev_runs:
result[run["id"]] = {
"feedback": feedback_map.get(run["id"], ""),
"outputs": run.get("outputs", []),
}
# Also add feedback for run_ids that had feedback but no matching run
for run_id, fb in feedback_map.items():
if run_id not in result:
result[run_id] = {"feedback": fb, "outputs": []}
return result
def generate_html(
runs: list[dict],
skill_name: str,
previous: dict[str, dict] | None = None,
benchmark: dict | None = None,
) -> str:
"""Generate the complete standalone HTML page with embedded data."""
template_path = Path(__file__).parent / "viewer.html"
template = template_path.read_text()
# Build previous_feedback and previous_outputs maps for the template
previous_feedback: dict[str, str] = {}
previous_outputs: dict[str, list[dict]] = {}
if previous:
for run_id, data in previous.items():
if data.get("feedback"):
previous_feedback[run_id] = data["feedback"]
if data.get("outputs"):
previous_outputs[run_id] = data["outputs"]
embedded = {
"skill_name": skill_name,
"runs": runs,
"previous_feedback": previous_feedback,
"previous_outputs": previous_outputs,
}
if benchmark:
embedded["benchmark"] = benchmark
data_json = json.dumps(embedded)
return template.replace("/*__EMBEDDED_DATA__*/", f"const EMBEDDED_DATA = {data_json};")
# ---------------------------------------------------------------------------
# HTTP server (stdlib only, zero dependencies)
# ---------------------------------------------------------------------------
def _kill_port(port: int) -> None:
"""Kill any process listening on the given port."""
try:
result = subprocess.run(
["lsof", "-ti", f":{port}"],
capture_output=True, text=True, timeout=5,
)
for pid_str in result.stdout.strip().split("\n"):
if pid_str.strip():
try:
os.kill(int(pid_str.strip()), signal.SIGTERM)
except (ProcessLookupError, ValueError):
pass
if result.stdout.strip():
time.sleep(0.5)
except subprocess.TimeoutExpired:
pass
except FileNotFoundError:
print("Note: lsof not found, cannot check if port is in use", file=sys.stderr)
class ReviewHandler(BaseHTTPRequestHandler):
"""Serves the review HTML and handles feedback saves.
Regenerates the HTML on each page load so that refreshing the browser
picks up new eval outputs without restarting the server.
"""
def __init__(
self,
workspace: Path,
skill_name: str,
feedback_path: Path,
previous: dict[str, dict],
benchmark_path: Path | None,
*args,
**kwargs,
):
self.workspace = workspace
self.skill_name = skill_name
self.feedback_path = feedback_path
self.previous = previous
self.benchmark_path = benchmark_path
super().__init__(*args, **kwargs)
def do_GET(self) -> None:
if self.path == "/" or self.path == "/index.html":
# Regenerate HTML on each request (re-scans workspace for new outputs)
runs = find_runs(self.workspace)
benchmark = None
if self.benchmark_path and self.benchmark_path.exists():
try:
benchmark = json.loads(self.benchmark_path.read_text())
except (json.JSONDecodeError, OSError):
pass
html = generate_html(runs, self.skill_name, self.previous, benchmark)
content = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
elif self.path == "/api/feedback":
data = b"{}"
if self.feedback_path.exists():
data = self.feedback_path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
else:
self.send_error(404)
def do_POST(self) -> None:
if self.path == "/api/feedback":
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
try:
data = json.loads(body)
if not isinstance(data, dict) or "reviews" not in data:
raise ValueError("Expected JSON object with 'reviews' key")
self.feedback_path.write_text(json.dumps(data, indent=2) + "\n")
resp = b'{"ok":true}'
self.send_response(200)
except (json.JSONDecodeError, OSError, ValueError) as e:
resp = json.dumps({"error": str(e)}).encode()
self.send_response(500)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
else:
self.send_error(404)
def log_message(self, format: str, *args: object) -> None:
# Suppress request logging to keep terminal clean
pass
def main() -> None:
parser = argparse.ArgumentParser(description="Generate and serve eval review")
parser.add_argument("workspace", type=Path, help="Path to workspace directory")
parser.add_argument("--port", "-p", type=int, default=3117, help="Server port (default: 3117)")
parser.add_argument("--skill-name", "-n", type=str, default=None, help="Skill name for header")
parser.add_argument(
"--previous-workspace", type=Path, default=None,
help="Path to previous iteration's workspace (shows old outputs and feedback as context)",
)
parser.add_argument(
"--benchmark", type=Path, default=None,
help="Path to benchmark.json to show in the Benchmark tab",
)
parser.add_argument(
"--static", "-s", type=Path, default=None,
help="Write standalone HTML to this path instead of starting a server",
)
args = parser.parse_args()
workspace = args.workspace.resolve()
if not workspace.is_dir():
print(f"Error: {workspace} is not a directory", file=sys.stderr)
sys.exit(1)
runs = find_runs(workspace)
if not runs:
print(f"No runs found in {workspace}", file=sys.stderr)
sys.exit(1)
skill_name = args.skill_name or workspace.name.replace("-workspace", "")
feedback_path = workspace / "feedback.json"
previous: dict[str, dict] = {}
if args.previous_workspace:
previous = load_previous_iteration(args.previous_workspace.resolve())
benchmark_path = args.benchmark.resolve() if args.benchmark else None
benchmark = None
if benchmark_path and benchmark_path.exists():
try:
benchmark = json.loads(benchmark_path.read_text())
except (json.JSONDecodeError, OSError):
pass
if args.static:
html = generate_html(runs, skill_name, previous, benchmark)
args.static.parent.mkdir(parents=True, exist_ok=True)
args.static.write_text(html)
print(f"\n Static viewer written to: {args.static}\n")
sys.exit(0)
# Kill any existing process on the target port
port = args.port
_kill_port(port)
handler = partial(ReviewHandler, workspace, skill_name, feedback_path, previous, benchmark_path)
try:
server = HTTPServer(("127.0.0.1", port), handler)
except OSError:
# Port still in use after kill attempt — find a free one
server = HTTPServer(("127.0.0.1", 0), handler)
port = server.server_address[1]
url = f"http://localhost:{port}"
print(f"\n Eval Viewer")
print(f" ─────────────────────────────────")
print(f" URL: {url}")
print(f" Workspace: {workspace}")
print(f" Feedback: {feedback_path}")
if previous:
print(f" Previous: {args.previous_workspace} ({len(previous)} runs)")
if benchmark_path:
print(f" Benchmark: {benchmark_path}")
print(f"\n Press Ctrl+C to stop.\n")
webbrowser.open(url)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
server.server_close()
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,430 @@
# JSON Schemas
This document defines the JSON schemas used by skill-creator.
---
## evals.json
Defines the evals for a skill. Located at `evals/evals.json` within the skill directory.
```json
{
"skill_name": "example-skill",
"evals": [
{
"id": 1,
"prompt": "User's example prompt",
"expected_output": "Description of expected result",
"files": ["evals/files/sample1.pdf"],
"expectations": [
"The output includes X",
"The skill used script Y"
]
}
]
}
```
**Fields:**
- `skill_name`: Name matching the skill's frontmatter
- `evals[].id`: Unique integer identifier
- `evals[].prompt`: The task to execute
- `evals[].expected_output`: Human-readable description of success
- `evals[].files`: Optional list of input file paths (relative to skill root)
- `evals[].expectations`: List of verifiable statements
---
## history.json
Tracks version progression in Improve mode. Located at workspace root.
```json
{
"started_at": "2026-01-15T10:30:00Z",
"skill_name": "pdf",
"current_best": "v2",
"iterations": [
{
"version": "v0",
"parent": null,
"expectation_pass_rate": 0.65,
"grading_result": "baseline",
"is_current_best": false
},
{
"version": "v1",
"parent": "v0",
"expectation_pass_rate": 0.75,
"grading_result": "won",
"is_current_best": false
},
{
"version": "v2",
"parent": "v1",
"expectation_pass_rate": 0.85,
"grading_result": "won",
"is_current_best": true
}
]
}
```
**Fields:**
- `started_at`: ISO timestamp of when improvement started
- `skill_name`: Name of the skill being improved
- `current_best`: Version identifier of the best performer
- `iterations[].version`: Version identifier (v0, v1, ...)
- `iterations[].parent`: Parent version this was derived from
- `iterations[].expectation_pass_rate`: Pass rate from grading
- `iterations[].grading_result`: "baseline", "won", "lost", or "tie"
- `iterations[].is_current_best`: Whether this is the current best version
---
## grading.json
Output from the grader agent. Located at `<run-dir>/grading.json`.
```json
{
"expectations": [
{
"text": "The output includes the name 'John Smith'",
"passed": true,
"evidence": "Found in transcript Step 3: 'Extracted names: John Smith, Sarah Johnson'"
},
{
"text": "The spreadsheet has a SUM formula in cell B10",
"passed": false,
"evidence": "No spreadsheet was created. The output was a text file."
}
],
"summary": {
"passed": 2,
"failed": 1,
"total": 3,
"pass_rate": 0.67
},
"execution_metrics": {
"tool_calls": {
"Read": 5,
"Write": 2,
"Bash": 8
},
"total_tool_calls": 15,
"total_steps": 6,
"errors_encountered": 0,
"output_chars": 12450,
"transcript_chars": 3200
},
"timing": {
"executor_duration_seconds": 165.0,
"grader_duration_seconds": 26.0,
"total_duration_seconds": 191.0
},
"claims": [
{
"claim": "The form has 12 fillable fields",
"type": "factual",
"verified": true,
"evidence": "Counted 12 fields in field_info.json"
}
],
"user_notes_summary": {
"uncertainties": ["Used 2023 data, may be stale"],
"needs_review": [],
"workarounds": ["Fell back to text overlay for non-fillable fields"]
},
"eval_feedback": {
"suggestions": [
{
"assertion": "The output includes the name 'John Smith'",
"reason": "A hallucinated document that mentions the name would also pass"
}
],
"overall": "Assertions check presence but not correctness."
}
}
```
**Fields:**
- `expectations[]`: Graded expectations with evidence
- `summary`: Aggregate pass/fail counts
- `execution_metrics`: Tool usage and output size (from executor's metrics.json)
- `timing`: Wall clock timing (from timing.json)
- `claims`: Extracted and verified claims from the output
- `user_notes_summary`: Issues flagged by the executor
- `eval_feedback`: (optional) Improvement suggestions for the evals, only present when the grader identifies issues worth raising
---
## metrics.json
Output from the executor agent. Located at `<run-dir>/outputs/metrics.json`.
```json
{
"tool_calls": {
"Read": 5,
"Write": 2,
"Bash": 8,
"Edit": 1,
"Glob": 2,
"Grep": 0
},
"total_tool_calls": 18,
"total_steps": 6,
"files_created": ["filled_form.pdf", "field_values.json"],
"errors_encountered": 0,
"output_chars": 12450,
"transcript_chars": 3200
}
```
**Fields:**
- `tool_calls`: Count per tool type
- `total_tool_calls`: Sum of all tool calls
- `total_steps`: Number of major execution steps
- `files_created`: List of output files created
- `errors_encountered`: Number of errors during execution
- `output_chars`: Total character count of output files
- `transcript_chars`: Character count of transcript
---
## timing.json
Wall clock timing for a run. Located at `<run-dir>/timing.json`.
**How to capture:** When a subagent task completes, the task notification includes `total_tokens` and `duration_ms`. Save these immediately — they are not persisted anywhere else and cannot be recovered after the fact.
```json
{
"total_tokens": 84852,
"duration_ms": 23332,
"total_duration_seconds": 23.3,
"executor_start": "2026-01-15T10:30:00Z",
"executor_end": "2026-01-15T10:32:45Z",
"executor_duration_seconds": 165.0,
"grader_start": "2026-01-15T10:32:46Z",
"grader_end": "2026-01-15T10:33:12Z",
"grader_duration_seconds": 26.0
}
```
---
## benchmark.json
Output from Benchmark mode. Located at `benchmarks/<timestamp>/benchmark.json`.
```json
{
"metadata": {
"skill_name": "pdf",
"skill_path": "/path/to/pdf",
"executor_model": "claude-sonnet-4-20250514",
"analyzer_model": "most-capable-model",
"timestamp": "2026-01-15T10:30:00Z",
"evals_run": [1, 2, 3],
"runs_per_configuration": 3
},
"runs": [
{
"eval_id": 1,
"eval_name": "Ocean",
"configuration": "with_skill",
"run_number": 1,
"result": {
"pass_rate": 0.85,
"passed": 6,
"failed": 1,
"total": 7,
"time_seconds": 42.5,
"tokens": 3800,
"tool_calls": 18,
"errors": 0
},
"expectations": [
{"text": "...", "passed": true, "evidence": "..."}
],
"notes": [
"Used 2023 data, may be stale",
"Fell back to text overlay for non-fillable fields"
]
}
],
"run_summary": {
"with_skill": {
"pass_rate": {"mean": 0.85, "stddev": 0.05, "min": 0.80, "max": 0.90},
"time_seconds": {"mean": 45.0, "stddev": 12.0, "min": 32.0, "max": 58.0},
"tokens": {"mean": 3800, "stddev": 400, "min": 3200, "max": 4100}
},
"without_skill": {
"pass_rate": {"mean": 0.35, "stddev": 0.08, "min": 0.28, "max": 0.45},
"time_seconds": {"mean": 32.0, "stddev": 8.0, "min": 24.0, "max": 42.0},
"tokens": {"mean": 2100, "stddev": 300, "min": 1800, "max": 2500}
},
"delta": {
"pass_rate": "+0.50",
"time_seconds": "+13.0",
"tokens": "+1700"
}
},
"notes": [
"Assertion 'Output is a PDF file' passes 100% in both configurations - may not differentiate skill value",
"Eval 3 shows high variance (50% ± 40%) - may be flaky or model-dependent",
"Without-skill runs consistently fail on table extraction expectations",
"Skill adds 13s average execution time but improves pass rate by 50%"
]
}
```
**Fields:**
- `metadata`: Information about the benchmark run
- `skill_name`: Name of the skill
- `timestamp`: When the benchmark was run
- `evals_run`: List of eval names or IDs
- `runs_per_configuration`: Number of runs per config (e.g. 3)
- `runs[]`: Individual run results
- `eval_id`: Numeric eval identifier
- `eval_name`: Human-readable eval name (used as section header in the viewer)
- `configuration`: Must be `"with_skill"` or `"without_skill"` (the viewer uses this exact string for grouping and color coding)
- `run_number`: Integer run number (1, 2, 3...)
- `result`: Nested object with `pass_rate`, `passed`, `total`, `time_seconds`, `tokens`, `errors`
- `run_summary`: Statistical aggregates per configuration
- `with_skill` / `without_skill`: Each contains `pass_rate`, `time_seconds`, `tokens` objects with `mean` and `stddev` fields
- `delta`: Difference strings like `"+0.50"`, `"+13.0"`, `"+1700"`
- `notes`: Freeform observations from the analyzer
**Important:** The viewer reads these field names exactly. Using `config` instead of `configuration`, or putting `pass_rate` at the top level of a run instead of nested under `result`, will cause the viewer to show empty/zero values. Always reference this schema when generating benchmark.json manually.
---
## comparison.json
Output from blind comparator. Located at `<grading-dir>/comparison-N.json`.
```json
{
"winner": "A",
"reasoning": "Output A provides a complete solution with proper formatting and all required fields. Output B is missing the date field and has formatting inconsistencies.",
"rubric": {
"A": {
"content": {
"correctness": 5,
"completeness": 5,
"accuracy": 4
},
"structure": {
"organization": 4,
"formatting": 5,
"usability": 4
},
"content_score": 4.7,
"structure_score": 4.3,
"overall_score": 9.0
},
"B": {
"content": {
"correctness": 3,
"completeness": 2,
"accuracy": 3
},
"structure": {
"organization": 3,
"formatting": 2,
"usability": 3
},
"content_score": 2.7,
"structure_score": 2.7,
"overall_score": 5.4
}
},
"output_quality": {
"A": {
"score": 9,
"strengths": ["Complete solution", "Well-formatted", "All fields present"],
"weaknesses": ["Minor style inconsistency in header"]
},
"B": {
"score": 5,
"strengths": ["Readable output", "Correct basic structure"],
"weaknesses": ["Missing date field", "Formatting inconsistencies", "Partial data extraction"]
}
},
"expectation_results": {
"A": {
"passed": 4,
"total": 5,
"pass_rate": 0.80,
"details": [
{"text": "Output includes name", "passed": true}
]
},
"B": {
"passed": 3,
"total": 5,
"pass_rate": 0.60,
"details": [
{"text": "Output includes name", "passed": true}
]
}
}
}
```
---
## analysis.json
Output from post-hoc analyzer. Located at `<grading-dir>/analysis.json`.
```json
{
"comparison_summary": {
"winner": "A",
"winner_skill": "path/to/winner/skill",
"loser_skill": "path/to/loser/skill",
"comparator_reasoning": "Brief summary of why comparator chose winner"
},
"winner_strengths": [
"Clear step-by-step instructions for handling multi-page documents",
"Included validation script that caught formatting errors"
],
"loser_weaknesses": [
"Vague instruction 'process the document appropriately' led to inconsistent behavior",
"No script for validation, agent had to improvise"
],
"instruction_following": {
"winner": {
"score": 9,
"issues": ["Minor: skipped optional logging step"]
},
"loser": {
"score": 6,
"issues": [
"Did not use the skill's formatting template",
"Invented own approach instead of following step 3"
]
}
},
"improvement_suggestions": [
{
"priority": "high",
"category": "instructions",
"suggestion": "Replace 'process the document appropriately' with explicit steps",
"expected_impact": "Would eliminate ambiguity that caused inconsistent behavior"
}
],
"transcript_insights": {
"winner_execution_pattern": "Read skill -> Followed 5-step process -> Used validation script",
"loser_execution_pattern": "Read skill -> Unclear on approach -> Tried 3 different methods"
}
}
```

View file

@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""
Aggregate individual run results into benchmark summary statistics.
Reads grading.json files from run directories and produces:
- run_summary with mean, stddev, min, max for each metric
- delta between with_skill and without_skill configurations
Usage:
python aggregate_benchmark.py <benchmark_dir>
Example:
python aggregate_benchmark.py benchmarks/2026-01-15T10-30-00/
The script supports two directory layouts:
Workspace layout (from skill-creator iterations):
<benchmark_dir>/
eval-N/
with_skill/
run-1/grading.json
run-2/grading.json
without_skill/
run-1/grading.json
run-2/grading.json
Legacy layout (with runs/ subdirectory):
<benchmark_dir>/
runs/
eval-N/
with_skill/
run-1/grading.json
without_skill/
run-1/grading.json
"""
import argparse
import json
import math
import sys
from datetime import datetime, timezone
from pathlib import Path
def calculate_stats(values: list[float]) -> dict:
"""Calculate mean, stddev, min, max for a list of values."""
if not values:
return {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0}
n = len(values)
mean = sum(values) / n
if n > 1:
variance = sum((x - mean) ** 2 for x in values) / (n - 1)
stddev = math.sqrt(variance)
else:
stddev = 0.0
return {
"mean": round(mean, 4),
"stddev": round(stddev, 4),
"min": round(min(values), 4),
"max": round(max(values), 4)
}
def load_run_results(benchmark_dir: Path) -> dict:
"""
Load all run results from a benchmark directory.
Returns dict keyed by config name (e.g. "with_skill"/"without_skill",
or "new_skill"/"old_skill"), each containing a list of run results.
"""
# Support both layouts: eval dirs directly under benchmark_dir, or under runs/
runs_dir = benchmark_dir / "runs"
if runs_dir.exists():
search_dir = runs_dir
elif list(benchmark_dir.glob("eval-*")):
search_dir = benchmark_dir
else:
print(f"No eval directories found in {benchmark_dir} or {benchmark_dir / 'runs'}")
return {}
results: dict[str, list] = {}
for eval_idx, eval_dir in enumerate(sorted(search_dir.glob("eval-*"))):
metadata_path = eval_dir / "eval_metadata.json"
if metadata_path.exists():
try:
with open(metadata_path) as mf:
eval_id = json.load(mf).get("eval_id", eval_idx)
except (json.JSONDecodeError, OSError):
eval_id = eval_idx
else:
try:
eval_id = int(eval_dir.name.split("-")[1])
except ValueError:
eval_id = eval_idx
# Discover config directories dynamically rather than hardcoding names
for config_dir in sorted(eval_dir.iterdir()):
if not config_dir.is_dir():
continue
# Skip non-config directories (inputs, outputs, etc.)
if not list(config_dir.glob("run-*")):
continue
config = config_dir.name
if config not in results:
results[config] = []
for run_dir in sorted(config_dir.glob("run-*")):
run_number = int(run_dir.name.split("-")[1])
grading_file = run_dir / "grading.json"
if not grading_file.exists():
print(f"Warning: grading.json not found in {run_dir}")
continue
try:
with open(grading_file) as f:
grading = json.load(f)
except json.JSONDecodeError as e:
print(f"Warning: Invalid JSON in {grading_file}: {e}")
continue
# Extract metrics
result = {
"eval_id": eval_id,
"run_number": run_number,
"pass_rate": grading.get("summary", {}).get("pass_rate", 0.0),
"passed": grading.get("summary", {}).get("passed", 0),
"failed": grading.get("summary", {}).get("failed", 0),
"total": grading.get("summary", {}).get("total", 0),
}
# Extract timing — check grading.json first, then sibling timing.json
timing = grading.get("timing", {})
result["time_seconds"] = timing.get("total_duration_seconds", 0.0)
timing_file = run_dir / "timing.json"
if result["time_seconds"] == 0.0 and timing_file.exists():
try:
with open(timing_file) as tf:
timing_data = json.load(tf)
result["time_seconds"] = timing_data.get("total_duration_seconds", 0.0)
result["tokens"] = timing_data.get("total_tokens", 0)
except json.JSONDecodeError:
pass
# Extract metrics if available
metrics = grading.get("execution_metrics", {})
result["tool_calls"] = metrics.get("total_tool_calls", 0)
if not result.get("tokens"):
result["tokens"] = metrics.get("output_chars", 0)
result["errors"] = metrics.get("errors_encountered", 0)
# Extract expectations — viewer requires fields: text, passed, evidence
raw_expectations = grading.get("expectations", [])
for exp in raw_expectations:
if "text" not in exp or "passed" not in exp:
print(f"Warning: expectation in {grading_file} missing required fields (text, passed, evidence): {exp}")
result["expectations"] = raw_expectations
# Extract notes from user_notes_summary
notes_summary = grading.get("user_notes_summary", {})
notes = []
notes.extend(notes_summary.get("uncertainties", []))
notes.extend(notes_summary.get("needs_review", []))
notes.extend(notes_summary.get("workarounds", []))
result["notes"] = notes
results[config].append(result)
return results
def aggregate_results(results: dict) -> dict:
"""
Aggregate run results into summary statistics.
Returns run_summary with stats for each configuration and delta.
"""
run_summary = {}
configs = list(results.keys())
for config in configs:
runs = results.get(config, [])
if not runs:
run_summary[config] = {
"pass_rate": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0},
"time_seconds": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0},
"tokens": {"mean": 0, "stddev": 0, "min": 0, "max": 0}
}
continue
pass_rates = [r["pass_rate"] for r in runs]
times = [r["time_seconds"] for r in runs]
tokens = [r.get("tokens", 0) for r in runs]
run_summary[config] = {
"pass_rate": calculate_stats(pass_rates),
"time_seconds": calculate_stats(times),
"tokens": calculate_stats(tokens)
}
# Calculate delta between the first two configs (if two exist)
if len(configs) >= 2:
primary = run_summary.get(configs[0], {})
baseline = run_summary.get(configs[1], {})
else:
primary = run_summary.get(configs[0], {}) if configs else {}
baseline = {}
delta_pass_rate = primary.get("pass_rate", {}).get("mean", 0) - baseline.get("pass_rate", {}).get("mean", 0)
delta_time = primary.get("time_seconds", {}).get("mean", 0) - baseline.get("time_seconds", {}).get("mean", 0)
delta_tokens = primary.get("tokens", {}).get("mean", 0) - baseline.get("tokens", {}).get("mean", 0)
run_summary["delta"] = {
"pass_rate": f"{delta_pass_rate:+.2f}",
"time_seconds": f"{delta_time:+.1f}",
"tokens": f"{delta_tokens:+.0f}"
}
return run_summary
def generate_benchmark(benchmark_dir: Path, skill_name: str = "", skill_path: str = "") -> dict:
"""
Generate complete benchmark.json from run results.
"""
results = load_run_results(benchmark_dir)
run_summary = aggregate_results(results)
# Build runs array for benchmark.json
runs = []
for config in results:
for result in results[config]:
runs.append({
"eval_id": result["eval_id"],
"configuration": config,
"run_number": result["run_number"],
"result": {
"pass_rate": result["pass_rate"],
"passed": result["passed"],
"failed": result["failed"],
"total": result["total"],
"time_seconds": result["time_seconds"],
"tokens": result.get("tokens", 0),
"tool_calls": result.get("tool_calls", 0),
"errors": result.get("errors", 0)
},
"expectations": result["expectations"],
"notes": result["notes"]
})
# Determine eval IDs from results
eval_ids = sorted(set(
r["eval_id"]
for config in results.values()
for r in config
))
benchmark = {
"metadata": {
"skill_name": skill_name or "<skill-name>",
"skill_path": skill_path or "<path/to/skill>",
"executor_model": "<model-name>",
"analyzer_model": "<model-name>",
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"evals_run": eval_ids,
"runs_per_configuration": 3
},
"runs": runs,
"run_summary": run_summary,
"notes": [] # To be filled by analyzer
}
return benchmark
def generate_markdown(benchmark: dict) -> str:
"""Generate human-readable benchmark.md from benchmark data."""
metadata = benchmark["metadata"]
run_summary = benchmark["run_summary"]
# Determine config names (excluding "delta")
configs = [k for k in run_summary if k != "delta"]
config_a = configs[0] if len(configs) >= 1 else "config_a"
config_b = configs[1] if len(configs) >= 2 else "config_b"
label_a = config_a.replace("_", " ").title()
label_b = config_b.replace("_", " ").title()
lines = [
f"# Skill Benchmark: {metadata['skill_name']}",
"",
f"**Model**: {metadata['executor_model']}",
f"**Date**: {metadata['timestamp']}",
f"**Evals**: {', '.join(map(str, metadata['evals_run']))} ({metadata['runs_per_configuration']} runs each per configuration)",
"",
"## Summary",
"",
f"| Metric | {label_a} | {label_b} | Delta |",
"|--------|------------|---------------|-------|",
]
a_summary = run_summary.get(config_a, {})
b_summary = run_summary.get(config_b, {})
delta = run_summary.get("delta", {})
# Format pass rate
a_pr = a_summary.get("pass_rate", {})
b_pr = b_summary.get("pass_rate", {})
lines.append(f"| Pass Rate | {a_pr.get('mean', 0)*100:.0f}% ± {a_pr.get('stddev', 0)*100:.0f}% | {b_pr.get('mean', 0)*100:.0f}% ± {b_pr.get('stddev', 0)*100:.0f}% | {delta.get('pass_rate', '')} |")
# Format time
a_time = a_summary.get("time_seconds", {})
b_time = b_summary.get("time_seconds", {})
lines.append(f"| Time | {a_time.get('mean', 0):.1f}s ± {a_time.get('stddev', 0):.1f}s | {b_time.get('mean', 0):.1f}s ± {b_time.get('stddev', 0):.1f}s | {delta.get('time_seconds', '')}s |")
# Format tokens
a_tokens = a_summary.get("tokens", {})
b_tokens = b_summary.get("tokens", {})
lines.append(f"| Tokens | {a_tokens.get('mean', 0):.0f} ± {a_tokens.get('stddev', 0):.0f} | {b_tokens.get('mean', 0):.0f} ± {b_tokens.get('stddev', 0):.0f} | {delta.get('tokens', '')} |")
# Notes section
if benchmark.get("notes"):
lines.extend([
"",
"## Notes",
""
])
for note in benchmark["notes"]:
lines.append(f"- {note}")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Aggregate benchmark run results into summary statistics"
)
parser.add_argument(
"benchmark_dir",
type=Path,
help="Path to the benchmark directory"
)
parser.add_argument(
"--skill-name",
default="",
help="Name of the skill being benchmarked"
)
parser.add_argument(
"--skill-path",
default="",
help="Path to the skill being benchmarked"
)
parser.add_argument(
"--output", "-o",
type=Path,
help="Output path for benchmark.json (default: <benchmark_dir>/benchmark.json)"
)
args = parser.parse_args()
if not args.benchmark_dir.exists():
print(f"Directory not found: {args.benchmark_dir}")
sys.exit(1)
# Generate benchmark
benchmark = generate_benchmark(args.benchmark_dir, args.skill_name, args.skill_path)
# Determine output paths
output_json = args.output or (args.benchmark_dir / "benchmark.json")
output_md = output_json.with_suffix(".md")
# Write benchmark.json
with open(output_json, "w") as f:
json.dump(benchmark, f, indent=2)
print(f"Generated: {output_json}")
# Write benchmark.md
markdown = generate_markdown(benchmark)
with open(output_md, "w") as f:
f.write(markdown)
print(f"Generated: {output_md}")
# Print summary
run_summary = benchmark["run_summary"]
configs = [k for k in run_summary if k != "delta"]
delta = run_summary.get("delta", {})
print(f"\nSummary:")
for config in configs:
pr = run_summary[config]["pass_rate"]["mean"]
label = config.replace("_", " ").title()
print(f" {label}: {pr*100:.1f}% pass rate")
print(f" Delta: {delta.get('pass_rate', '')}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""Generate an HTML report from run_loop.py output.
Takes the JSON output from run_loop.py and generates a visual HTML report
showing each description attempt with check/x for each test case.
Distinguishes between train and test queries.
"""
import argparse
import html
import json
import sys
from pathlib import Path
def generate_html(data: dict, auto_refresh: bool = False, skill_name: str = "") -> str:
"""Generate HTML report from loop output data. If auto_refresh is True, adds a meta refresh tag."""
history = data.get("history", [])
holdout = data.get("holdout", 0)
title_prefix = html.escape(skill_name + " \u2014 ") if skill_name else ""
# Get all unique queries from train and test sets, with should_trigger info
train_queries: list[dict] = []
test_queries: list[dict] = []
if history:
for r in history[0].get("train_results", history[0].get("results", [])):
train_queries.append({"query": r["query"], "should_trigger": r.get("should_trigger", True)})
if history[0].get("test_results"):
for r in history[0].get("test_results", []):
test_queries.append({"query": r["query"], "should_trigger": r.get("should_trigger", True)})
refresh_tag = ' <meta http-equiv="refresh" content="5">\n' if auto_refresh else ""
html_parts = ["""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
""" + refresh_tag + """ <title>""" + title_prefix + """Skill Description Optimization</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@500;600&family=Lora:wght@400;500&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Lora', Georgia, serif;
max-width: 100%;
margin: 0 auto;
padding: 20px;
background: #faf9f5;
color: #141413;
}
h1 { font-family: 'Poppins', sans-serif; color: #141413; }
.explainer {
background: white;
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
border: 1px solid #e8e6dc;
color: #b0aea5;
font-size: 0.875rem;
line-height: 1.6;
}
.summary {
background: white;
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
border: 1px solid #e8e6dc;
}
.summary p { margin: 5px 0; }
.best { color: #788c5d; font-weight: bold; }
.table-container {
overflow-x: auto;
width: 100%;
}
table {
border-collapse: collapse;
background: white;
border: 1px solid #e8e6dc;
border-radius: 6px;
font-size: 12px;
min-width: 100%;
}
th, td {
padding: 8px;
text-align: left;
border: 1px solid #e8e6dc;
white-space: normal;
word-wrap: break-word;
}
th {
font-family: 'Poppins', sans-serif;
background: #141413;
color: #faf9f5;
font-weight: 500;
}
th.test-col {
background: #6a9bcc;
}
th.query-col { min-width: 200px; }
td.description {
font-family: monospace;
font-size: 11px;
word-wrap: break-word;
max-width: 400px;
}
td.result {
text-align: center;
font-size: 16px;
min-width: 40px;
}
td.test-result {
background: #f0f6fc;
}
.pass { color: #788c5d; }
.fail { color: #c44; }
.rate {
font-size: 9px;
color: #b0aea5;
display: block;
}
tr:hover { background: #faf9f5; }
.score {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-weight: bold;
font-size: 11px;
}
.score-good { background: #eef2e8; color: #788c5d; }
.score-ok { background: #fef3c7; color: #d97706; }
.score-bad { background: #fceaea; color: #c44; }
.train-label { color: #b0aea5; font-size: 10px; }
.test-label { color: #6a9bcc; font-size: 10px; font-weight: bold; }
.best-row { background: #f5f8f2; }
th.positive-col { border-bottom: 3px solid #788c5d; }
th.negative-col { border-bottom: 3px solid #c44; }
th.test-col.positive-col { border-bottom: 3px solid #788c5d; }
th.test-col.negative-col { border-bottom: 3px solid #c44; }
.legend { font-family: 'Poppins', sans-serif; display: flex; gap: 20px; margin-bottom: 10px; font-size: 13px; align-items: center; }
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-swatch { width: 16px; height: 16px; border-radius: 3px; display: inline-block; }
.swatch-positive { background: #141413; border-bottom: 3px solid #788c5d; }
.swatch-negative { background: #141413; border-bottom: 3px solid #c44; }
.swatch-test { background: #6a9bcc; }
.swatch-train { background: #141413; }
</style>
</head>
<body>
<h1>""" + title_prefix + """Skill Description Optimization</h1>
<div class="explainer">
<strong>Optimizing your skill's description.</strong> This page updates automatically as Claude tests different versions of your skill's description. Each row is an iteration a new description attempt. The columns show test queries: green checkmarks mean the skill triggered correctly (or correctly didn't trigger), red crosses mean it got it wrong. The "Train" score shows performance on queries used to improve the description; the "Test" score shows performance on held-out queries the optimizer hasn't seen. When it's done, Claude will apply the best-performing description to your skill.
</div>
"""]
# Summary section
best_test_score = data.get('best_test_score')
best_train_score = data.get('best_train_score')
html_parts.append(f"""
<div class="summary">
<p><strong>Original:</strong> {html.escape(data.get('original_description', 'N/A'))}</p>
<p class="best"><strong>Best:</strong> {html.escape(data.get('best_description', 'N/A'))}</p>
<p><strong>Best Score:</strong> {data.get('best_score', 'N/A')} {'(test)' if best_test_score else '(train)'}</p>
<p><strong>Iterations:</strong> {data.get('iterations_run', 0)} | <strong>Train:</strong> {data.get('train_size', '?')} | <strong>Test:</strong> {data.get('test_size', '?')}</p>
</div>
""")
# Legend
html_parts.append("""
<div class="legend">
<span style="font-weight:600">Query columns:</span>
<span class="legend-item"><span class="legend-swatch swatch-positive"></span> Should trigger</span>
<span class="legend-item"><span class="legend-swatch swatch-negative"></span> Should NOT trigger</span>
<span class="legend-item"><span class="legend-swatch swatch-train"></span> Train</span>
<span class="legend-item"><span class="legend-swatch swatch-test"></span> Test</span>
</div>
""")
# Table header
html_parts.append("""
<div class="table-container">
<table>
<thead>
<tr>
<th>Iter</th>
<th>Train</th>
<th>Test</th>
<th class="query-col">Description</th>
""")
# Add column headers for train queries
for qinfo in train_queries:
polarity = "positive-col" if qinfo["should_trigger"] else "negative-col"
html_parts.append(f' <th class="{polarity}">{html.escape(qinfo["query"])}</th>\n')
# Add column headers for test queries (different color)
for qinfo in test_queries:
polarity = "positive-col" if qinfo["should_trigger"] else "negative-col"
html_parts.append(f' <th class="test-col {polarity}">{html.escape(qinfo["query"])}</th>\n')
html_parts.append(""" </tr>
</thead>
<tbody>
""")
# Find best iteration for highlighting
if test_queries:
best_iter = max(history, key=lambda h: h.get("test_passed") or 0).get("iteration")
else:
best_iter = max(history, key=lambda h: h.get("train_passed", h.get("passed", 0))).get("iteration")
# Add rows for each iteration
for h in history:
iteration = h.get("iteration", "?")
train_passed = h.get("train_passed", h.get("passed", 0))
train_total = h.get("train_total", h.get("total", 0))
test_passed = h.get("test_passed")
test_total = h.get("test_total")
description = h.get("description", "")
train_results = h.get("train_results", h.get("results", []))
test_results = h.get("test_results", [])
# Create lookups for results by query
train_by_query = {r["query"]: r for r in train_results}
test_by_query = {r["query"]: r for r in test_results} if test_results else {}
# Compute aggregate correct/total runs across all retries
def aggregate_runs(results: list[dict]) -> tuple[int, int]:
correct = 0
total = 0
for r in results:
runs = r.get("runs", 0)
triggers = r.get("triggers", 0)
total += runs
if r.get("should_trigger", True):
correct += triggers
else:
correct += runs - triggers
return correct, total
train_correct, train_runs = aggregate_runs(train_results)
test_correct, test_runs = aggregate_runs(test_results)
# Determine score classes
def score_class(correct: int, total: int) -> str:
if total > 0:
ratio = correct / total
if ratio >= 0.8:
return "score-good"
elif ratio >= 0.5:
return "score-ok"
return "score-bad"
train_class = score_class(train_correct, train_runs)
test_class = score_class(test_correct, test_runs)
row_class = "best-row" if iteration == best_iter else ""
html_parts.append(f""" <tr class="{row_class}">
<td>{iteration}</td>
<td><span class="score {train_class}">{train_correct}/{train_runs}</span></td>
<td><span class="score {test_class}">{test_correct}/{test_runs}</span></td>
<td class="description">{html.escape(description)}</td>
""")
# Add result for each train query
for qinfo in train_queries:
r = train_by_query.get(qinfo["query"], {})
did_pass = r.get("pass", False)
triggers = r.get("triggers", 0)
runs = r.get("runs", 0)
icon = "" if did_pass else ""
css_class = "pass" if did_pass else "fail"
html_parts.append(f' <td class="result {css_class}">{icon}<span class="rate">{triggers}/{runs}</span></td>\n')
# Add result for each test query (with different background)
for qinfo in test_queries:
r = test_by_query.get(qinfo["query"], {})
did_pass = r.get("pass", False)
triggers = r.get("triggers", 0)
runs = r.get("runs", 0)
icon = "" if did_pass else ""
css_class = "pass" if did_pass else "fail"
html_parts.append(f' <td class="result test-result {css_class}">{icon}<span class="rate">{triggers}/{runs}</span></td>\n')
html_parts.append(" </tr>\n")
html_parts.append(""" </tbody>
</table>
</div>
""")
html_parts.append("""
</body>
</html>
""")
return "".join(html_parts)
def main():
parser = argparse.ArgumentParser(description="Generate HTML report from run_loop output")
parser.add_argument("input", help="Path to JSON output from run_loop.py (or - for stdin)")
parser.add_argument("-o", "--output", default=None, help="Output HTML file (default: stdout)")
parser.add_argument("--skill-name", default="", help="Skill name to include in the report title")
args = parser.parse_args()
if args.input == "-":
data = json.load(sys.stdin)
else:
data = json.loads(Path(args.input).read_text())
html_output = generate_html(data, skill_name=args.skill_name)
if args.output:
Path(args.output).write_text(html_output)
print(f"Report written to {args.output}", file=sys.stderr)
else:
print(html_output)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Improve a skill description based on eval results.
Takes eval results (from run_eval.py) and generates an improved description
using Claude with extended thinking.
"""
import argparse
import json
import re
import sys
from pathlib import Path
import anthropic
from scripts.utils import parse_skill_md
def improve_description(
client: anthropic.Anthropic,
skill_name: str,
skill_content: str,
current_description: str,
eval_results: dict,
history: list[dict],
model: str,
test_results: dict | None = None,
log_dir: Path | None = None,
iteration: int | None = None,
) -> str:
"""Call Claude to improve the description based on eval results."""
failed_triggers = [
r for r in eval_results["results"]
if r["should_trigger"] and not r["pass"]
]
false_triggers = [
r for r in eval_results["results"]
if not r["should_trigger"] and not r["pass"]
]
# Build scores summary
train_score = f"{eval_results['summary']['passed']}/{eval_results['summary']['total']}"
if test_results:
test_score = f"{test_results['summary']['passed']}/{test_results['summary']['total']}"
scores_summary = f"Train: {train_score}, Test: {test_score}"
else:
scores_summary = f"Train: {train_score}"
prompt = f"""You are optimizing a skill description for a Claude Code skill called "{skill_name}". A "skill" is sort of like a prompt, but with progressive disclosure -- there's a title and description that Claude sees when deciding whether to use the skill, and then if it does use the skill, it reads the .md file which has lots more details and potentially links to other resources in the skill folder like helper files and scripts and additional documentation or examples.
The description appears in Claude's "available_skills" list. When a user sends a query, Claude decides whether to invoke the skill based solely on the title and on this description. Your goal is to write a description that triggers for relevant queries, and doesn't trigger for irrelevant ones.
Here's the current description:
<current_description>
"{current_description}"
</current_description>
Current scores ({scores_summary}):
<scores_summary>
"""
if failed_triggers:
prompt += "FAILED TO TRIGGER (should have triggered but didn't):\n"
for r in failed_triggers:
prompt += f' - "{r["query"]}" (triggered {r["triggers"]}/{r["runs"]} times)\n'
prompt += "\n"
if false_triggers:
prompt += "FALSE TRIGGERS (triggered but shouldn't have):\n"
for r in false_triggers:
prompt += f' - "{r["query"]}" (triggered {r["triggers"]}/{r["runs"]} times)\n'
prompt += "\n"
if history:
prompt += "PREVIOUS ATTEMPTS (do NOT repeat these — try something structurally different):\n\n"
for h in history:
train_s = f"{h.get('train_passed', h.get('passed', 0))}/{h.get('train_total', h.get('total', 0))}"
test_s = f"{h.get('test_passed', '?')}/{h.get('test_total', '?')}" if h.get('test_passed') is not None else None
score_str = f"train={train_s}" + (f", test={test_s}" if test_s else "")
prompt += f'<attempt {score_str}>\n'
prompt += f'Description: "{h["description"]}"\n'
if "results" in h:
prompt += "Train results:\n"
for r in h["results"]:
status = "PASS" if r["pass"] else "FAIL"
prompt += f' [{status}] "{r["query"][:80]}" (triggered {r["triggers"]}/{r["runs"]})\n'
if h.get("note"):
prompt += f'Note: {h["note"]}\n'
prompt += "</attempt>\n\n"
prompt += f"""</scores_summary>
Skill content (for context on what the skill does):
<skill_content>
{skill_content}
</skill_content>
Based on the failures, write a new and improved description that is more likely to trigger correctly. When I say "based on the failures", it's a bit of a tricky line to walk because we don't want to overfit to the specific cases you're seeing. So what I DON'T want you to do is produce an ever-expanding list of specific queries that this skill should or shouldn't trigger for. Instead, try to generalize from the failures to broader categories of user intent and situations where this skill would be useful or not useful. The reason for this is twofold:
1. Avoid overfitting
2. The list might get loooong and it's injected into ALL queries and there might be a lot of skills, so we don't want to blow too much space on any given description.
Concretely, your description should not be more than about 100-200 words, even if that comes at the cost of accuracy.
Here are some tips that we've found to work well in writing these descriptions:
- The skill should be phrased in the imperative -- "Use this skill for" rather than "this skill does"
- The skill description should focus on the user's intent, what they are trying to achieve, vs. the implementation details of how the skill works.
- The description competes with other skills for Claude's attention — make it distinctive and immediately recognizable.
- If you're getting lots of failures after repeated attempts, change things up. Try different sentence structures or wordings.
I'd encourage you to be creative and mix up the style in different iterations since you'll have multiple opportunities to try different approaches and we'll just grab the highest-scoring one at the end.
Please respond with only the new description text in <new_description> tags, nothing else."""
response = client.messages.create(
model=model,
max_tokens=16000,
thinking={
"type": "enabled",
"budget_tokens": 10000,
},
messages=[{"role": "user", "content": prompt}],
)
# Extract thinking and text from response
thinking_text = ""
text = ""
for block in response.content:
if block.type == "thinking":
thinking_text = block.thinking
elif block.type == "text":
text = block.text
# Parse out the <new_description> tags
match = re.search(r"<new_description>(.*?)</new_description>", text, re.DOTALL)
description = match.group(1).strip().strip('"') if match else text.strip().strip('"')
# Log the transcript
transcript: dict = {
"iteration": iteration,
"prompt": prompt,
"thinking": thinking_text,
"response": text,
"parsed_description": description,
"char_count": len(description),
"over_limit": len(description) > 1024,
}
# If over 1024 chars, ask the model to shorten it
if len(description) > 1024:
shorten_prompt = f"Your description is {len(description)} characters, which exceeds the hard 1024 character limit. Please rewrite it to be under 1024 characters while preserving the most important trigger words and intent coverage. Respond with only the new description in <new_description> tags."
shorten_response = client.messages.create(
model=model,
max_tokens=16000,
thinking={
"type": "enabled",
"budget_tokens": 10000,
},
messages=[
{"role": "user", "content": prompt},
{"role": "assistant", "content": text},
{"role": "user", "content": shorten_prompt},
],
)
shorten_thinking = ""
shorten_text = ""
for block in shorten_response.content:
if block.type == "thinking":
shorten_thinking = block.thinking
elif block.type == "text":
shorten_text = block.text
match = re.search(r"<new_description>(.*?)</new_description>", shorten_text, re.DOTALL)
shortened = match.group(1).strip().strip('"') if match else shorten_text.strip().strip('"')
transcript["rewrite_prompt"] = shorten_prompt
transcript["rewrite_thinking"] = shorten_thinking
transcript["rewrite_response"] = shorten_text
transcript["rewrite_description"] = shortened
transcript["rewrite_char_count"] = len(shortened)
description = shortened
transcript["final_description"] = description
if log_dir:
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / f"improve_iter_{iteration or 'unknown'}.json"
log_file.write_text(json.dumps(transcript, indent=2))
return description
def main():
parser = argparse.ArgumentParser(description="Improve a skill description based on eval results")
parser.add_argument("--eval-results", required=True, help="Path to eval results JSON (from run_eval.py)")
parser.add_argument("--skill-path", required=True, help="Path to skill directory")
parser.add_argument("--history", default=None, help="Path to history JSON (previous attempts)")
parser.add_argument("--model", required=True, help="Model for improvement")
parser.add_argument("--verbose", action="store_true", help="Print thinking to stderr")
args = parser.parse_args()
skill_path = Path(args.skill_path)
if not (skill_path / "SKILL.md").exists():
print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr)
sys.exit(1)
eval_results = json.loads(Path(args.eval_results).read_text())
history = []
if args.history:
history = json.loads(Path(args.history).read_text())
name, _, content = parse_skill_md(skill_path)
current_description = eval_results["description"]
if args.verbose:
print(f"Current: {current_description}", file=sys.stderr)
print(f"Score: {eval_results['summary']['passed']}/{eval_results['summary']['total']}", file=sys.stderr)
client = anthropic.Anthropic()
new_description = improve_description(
client=client,
skill_name=name,
skill_content=content,
current_description=current_description,
eval_results=eval_results,
history=history,
model=args.model,
)
if args.verbose:
print(f"Improved: {new_description}", file=sys.stderr)
# Output as JSON with both the new description and updated history
output = {
"description": new_description,
"history": history + [{
"description": current_description,
"passed": eval_results["summary"]["passed"],
"failed": eval_results["summary"]["failed"],
"total": eval_results["summary"]["total"],
"results": eval_results["results"],
}],
}
print(json.dumps(output, indent=2))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Skill Packager - Creates a distributable .skill file of a skill folder
Usage:
python utils/package_skill.py <path/to/skill-folder> [output-directory]
Example:
python utils/package_skill.py skills/public/my-skill
python utils/package_skill.py skills/public/my-skill ./dist
"""
import fnmatch
import sys
import zipfile
from pathlib import Path
from scripts.quick_validate import validate_skill
# Patterns to exclude when packaging skills.
EXCLUDE_DIRS = {"__pycache__", "node_modules"}
EXCLUDE_GLOBS = {"*.pyc"}
EXCLUDE_FILES = {".DS_Store"}
# Directories excluded only at the skill root (not when nested deeper).
ROOT_EXCLUDE_DIRS = {"evals"}
def should_exclude(rel_path: Path) -> bool:
"""Check if a path should be excluded from packaging."""
parts = rel_path.parts
if any(part in EXCLUDE_DIRS for part in parts):
return True
# rel_path is relative to skill_path.parent, so parts[0] is the skill
# folder name and parts[1] (if present) is the first subdir.
if len(parts) > 1 and parts[1] in ROOT_EXCLUDE_DIRS:
return True
name = rel_path.name
if name in EXCLUDE_FILES:
return True
return any(fnmatch.fnmatch(name, pat) for pat in EXCLUDE_GLOBS)
def package_skill(skill_path, output_dir=None):
"""
Package a skill folder into a .skill file.
Args:
skill_path: Path to the skill folder
output_dir: Optional output directory for the .skill file (defaults to current directory)
Returns:
Path to the created .skill file, or None if error
"""
skill_path = Path(skill_path).resolve()
# Validate skill folder exists
if not skill_path.exists():
print(f"❌ Error: Skill folder not found: {skill_path}")
return None
if not skill_path.is_dir():
print(f"❌ Error: Path is not a directory: {skill_path}")
return None
# Validate SKILL.md exists
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
print(f"❌ Error: SKILL.md not found in {skill_path}")
return None
# Run validation before packaging
print("🔍 Validating skill...")
valid, message = validate_skill(skill_path)
if not valid:
print(f"❌ Validation failed: {message}")
print(" Please fix the validation errors before packaging.")
return None
print(f"{message}\n")
# Determine output location
skill_name = skill_path.name
if output_dir:
output_path = Path(output_dir).resolve()
output_path.mkdir(parents=True, exist_ok=True)
else:
output_path = Path.cwd()
skill_filename = output_path / f"{skill_name}.skill"
# Create the .skill file (zip format)
try:
with zipfile.ZipFile(skill_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Walk through the skill directory, excluding build artifacts
for file_path in skill_path.rglob('*'):
if not file_path.is_file():
continue
arcname = file_path.relative_to(skill_path.parent)
if should_exclude(arcname):
print(f" Skipped: {arcname}")
continue
zipf.write(file_path, arcname)
print(f" Added: {arcname}")
print(f"\n✅ Successfully packaged skill to: {skill_filename}")
return skill_filename
except Exception as e:
print(f"❌ Error creating .skill file: {e}")
return None
def main():
if len(sys.argv) < 2:
print("Usage: python utils/package_skill.py <path/to/skill-folder> [output-directory]")
print("\nExample:")
print(" python utils/package_skill.py skills/public/my-skill")
print(" python utils/package_skill.py skills/public/my-skill ./dist")
sys.exit(1)
skill_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
print(f"📦 Packaging skill: {skill_path}")
if output_dir:
print(f" Output directory: {output_dir}")
print()
result = package_skill(skill_path, output_dir)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
Quick validation script for skills - minimal version
"""
import sys
import os
import re
import yaml
from pathlib import Path
def validate_skill(skill_path):
"""Basic validation of a skill"""
skill_path = Path(skill_path)
# Check SKILL.md exists
skill_md = skill_path / 'SKILL.md'
if not skill_md.exists():
return False, "SKILL.md not found"
# Read and validate frontmatter
content = skill_md.read_text()
if not content.startswith('---'):
return False, "No YAML frontmatter found"
# Extract frontmatter
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not match:
return False, "Invalid frontmatter format"
frontmatter_text = match.group(1)
# Parse YAML frontmatter
try:
frontmatter = yaml.safe_load(frontmatter_text)
if not isinstance(frontmatter, dict):
return False, "Frontmatter must be a YAML dictionary"
except yaml.YAMLError as e:
return False, f"Invalid YAML in frontmatter: {e}"
# Define allowed properties
ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata', 'compatibility'}
# Check for unexpected properties (excluding nested keys under metadata)
unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES
if unexpected_keys:
return False, (
f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. "
f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}"
)
# Check required fields
if 'name' not in frontmatter:
return False, "Missing 'name' in frontmatter"
if 'description' not in frontmatter:
return False, "Missing 'description' in frontmatter"
# Extract name for validation
name = frontmatter.get('name', '')
if not isinstance(name, str):
return False, f"Name must be a string, got {type(name).__name__}"
name = name.strip()
if name:
# Check naming convention (kebab-case: lowercase with hyphens)
if not re.match(r'^[a-z0-9-]+$', name):
return False, f"Name '{name}' should be kebab-case (lowercase letters, digits, and hyphens only)"
if name.startswith('-') or name.endswith('-') or '--' in name:
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens"
# Check name length (max 64 characters per spec)
if len(name) > 64:
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters."
# Extract and validate description
description = frontmatter.get('description', '')
if not isinstance(description, str):
return False, f"Description must be a string, got {type(description).__name__}"
description = description.strip()
if description:
# Check for angle brackets
if '<' in description or '>' in description:
return False, "Description cannot contain angle brackets (< or >)"
# Check description length (max 1024 characters per spec)
if len(description) > 1024:
return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters."
# Validate compatibility field if present (optional)
compatibility = frontmatter.get('compatibility', '')
if compatibility:
if not isinstance(compatibility, str):
return False, f"Compatibility must be a string, got {type(compatibility).__name__}"
if len(compatibility) > 500:
return False, f"Compatibility is too long ({len(compatibility)} characters). Maximum is 500 characters."
return True, "Skill is valid!"
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python quick_validate.py <skill_directory>")
sys.exit(1)
valid, message = validate_skill(sys.argv[1])
print(message)
sys.exit(0 if valid else 1)

View file

@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""Run trigger evaluation for a skill description.
Tests whether a skill's description causes Claude to trigger (read the skill)
for a set of queries. Outputs results as JSON.
"""
import argparse
import json
import os
import select
import subprocess
import sys
import time
import uuid
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from scripts.utils import parse_skill_md
def find_project_root() -> Path:
"""Find the project root by walking up from cwd looking for .claude/.
Mimics how Claude Code discovers its project root, so the command file
we create ends up where claude -p will look for it.
"""
current = Path.cwd()
for parent in [current, *current.parents]:
if (parent / ".claude").is_dir():
return parent
return current
def run_single_query(
query: str,
skill_name: str,
skill_description: str,
timeout: int,
project_root: str,
model: str | None = None,
) -> bool:
"""Run a single query and return whether the skill was triggered.
Creates a command file in .claude/commands/ so it appears in Claude's
available_skills list, then runs `claude -p` with the raw query.
Uses --include-partial-messages to detect triggering early from
stream events (content_block_start) rather than waiting for the
full assistant message, which only arrives after tool execution.
"""
unique_id = uuid.uuid4().hex[:8]
clean_name = f"{skill_name}-skill-{unique_id}"
project_commands_dir = Path(project_root) / ".claude" / "commands"
command_file = project_commands_dir / f"{clean_name}.md"
try:
project_commands_dir.mkdir(parents=True, exist_ok=True)
# Use YAML block scalar to avoid breaking on quotes in description
indented_desc = "\n ".join(skill_description.split("\n"))
command_content = (
f"---\n"
f"description: |\n"
f" {indented_desc}\n"
f"---\n\n"
f"# {skill_name}\n\n"
f"This skill handles: {skill_description}\n"
)
command_file.write_text(command_content)
cmd = [
"claude",
"-p", query,
"--output-format", "stream-json",
"--verbose",
"--include-partial-messages",
]
if model:
cmd.extend(["--model", model])
# Remove CLAUDECODE env var to allow nesting claude -p inside a
# Claude Code session. The guard is for interactive terminal conflicts;
# programmatic subprocess usage is safe.
env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
cwd=project_root,
env=env,
)
triggered = False
start_time = time.time()
buffer = ""
# Track state for stream event detection
pending_tool_name = None
accumulated_json = ""
try:
while time.time() - start_time < timeout:
if process.poll() is not None:
remaining = process.stdout.read()
if remaining:
buffer += remaining.decode("utf-8", errors="replace")
break
ready, _, _ = select.select([process.stdout], [], [], 1.0)
if not ready:
continue
chunk = os.read(process.stdout.fileno(), 8192)
if not chunk:
break
buffer += chunk.decode("utf-8", errors="replace")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
# Early detection via stream events
if event.get("type") == "stream_event":
se = event.get("event", {})
se_type = se.get("type", "")
if se_type == "content_block_start":
cb = se.get("content_block", {})
if cb.get("type") == "tool_use":
tool_name = cb.get("name", "")
if tool_name in ("Skill", "Read"):
pending_tool_name = tool_name
accumulated_json = ""
else:
return False
elif se_type == "content_block_delta" and pending_tool_name:
delta = se.get("delta", {})
if delta.get("type") == "input_json_delta":
accumulated_json += delta.get("partial_json", "")
if clean_name in accumulated_json:
return True
elif se_type in ("content_block_stop", "message_stop"):
if pending_tool_name:
return clean_name in accumulated_json
if se_type == "message_stop":
return False
# Fallback: full assistant message
elif event.get("type") == "assistant":
message = event.get("message", {})
for content_item in message.get("content", []):
if content_item.get("type") != "tool_use":
continue
tool_name = content_item.get("name", "")
tool_input = content_item.get("input", {})
if tool_name == "Skill" and clean_name in tool_input.get("skill", ""):
triggered = True
elif tool_name == "Read" and clean_name in tool_input.get("file_path", ""):
triggered = True
return triggered
elif event.get("type") == "result":
return triggered
finally:
# Clean up process on any exit path (return, exception, timeout)
if process.poll() is None:
process.kill()
process.wait()
return triggered
finally:
if command_file.exists():
command_file.unlink()
def run_eval(
eval_set: list[dict],
skill_name: str,
description: str,
num_workers: int,
timeout: int,
project_root: Path,
runs_per_query: int = 1,
trigger_threshold: float = 0.5,
model: str | None = None,
) -> dict:
"""Run the full eval set and return results."""
results = []
with ProcessPoolExecutor(max_workers=num_workers) as executor:
future_to_info = {}
for item in eval_set:
for run_idx in range(runs_per_query):
future = executor.submit(
run_single_query,
item["query"],
skill_name,
description,
timeout,
str(project_root),
model,
)
future_to_info[future] = (item, run_idx)
query_triggers: dict[str, list[bool]] = {}
query_items: dict[str, dict] = {}
for future in as_completed(future_to_info):
item, _ = future_to_info[future]
query = item["query"]
query_items[query] = item
if query not in query_triggers:
query_triggers[query] = []
try:
query_triggers[query].append(future.result())
except Exception as e:
print(f"Warning: query failed: {e}", file=sys.stderr)
query_triggers[query].append(False)
for query, triggers in query_triggers.items():
item = query_items[query]
trigger_rate = sum(triggers) / len(triggers)
should_trigger = item["should_trigger"]
if should_trigger:
did_pass = trigger_rate >= trigger_threshold
else:
did_pass = trigger_rate < trigger_threshold
results.append({
"query": query,
"should_trigger": should_trigger,
"trigger_rate": trigger_rate,
"triggers": sum(triggers),
"runs": len(triggers),
"pass": did_pass,
})
passed = sum(1 for r in results if r["pass"])
total = len(results)
return {
"skill_name": skill_name,
"description": description,
"results": results,
"summary": {
"total": total,
"passed": passed,
"failed": total - passed,
},
}
def main():
parser = argparse.ArgumentParser(description="Run trigger evaluation for a skill description")
parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file")
parser.add_argument("--skill-path", required=True, help="Path to skill directory")
parser.add_argument("--description", default=None, help="Override description to test")
parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers")
parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds")
parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query")
parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold")
parser.add_argument("--model", default=None, help="Model to use for claude -p (default: user's configured model)")
parser.add_argument("--verbose", action="store_true", help="Print progress to stderr")
args = parser.parse_args()
eval_set = json.loads(Path(args.eval_set).read_text())
skill_path = Path(args.skill_path)
if not (skill_path / "SKILL.md").exists():
print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr)
sys.exit(1)
name, original_description, content = parse_skill_md(skill_path)
description = args.description or original_description
project_root = find_project_root()
if args.verbose:
print(f"Evaluating: {description}", file=sys.stderr)
output = run_eval(
eval_set=eval_set,
skill_name=name,
description=description,
num_workers=args.num_workers,
timeout=args.timeout,
project_root=project_root,
runs_per_query=args.runs_per_query,
trigger_threshold=args.trigger_threshold,
model=args.model,
)
if args.verbose:
summary = output["summary"]
print(f"Results: {summary['passed']}/{summary['total']} passed", file=sys.stderr)
for r in output["results"]:
status = "PASS" if r["pass"] else "FAIL"
rate_str = f"{r['triggers']}/{r['runs']}"
print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:70]}", file=sys.stderr)
print(json.dumps(output, indent=2))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Run the eval + improve loop until all pass or max iterations reached.
Combines run_eval.py and improve_description.py in a loop, tracking history
and returning the best description found. Supports train/test split to prevent
overfitting.
"""
import argparse
import json
import random
import sys
import tempfile
import time
import webbrowser
from pathlib import Path
import anthropic
from scripts.generate_report import generate_html
from scripts.improve_description import improve_description
from scripts.run_eval import find_project_root, run_eval
from scripts.utils import parse_skill_md
def split_eval_set(eval_set: list[dict], holdout: float, seed: int = 42) -> tuple[list[dict], list[dict]]:
"""Split eval set into train and test sets, stratified by should_trigger."""
random.seed(seed)
# Separate by should_trigger
trigger = [e for e in eval_set if e["should_trigger"]]
no_trigger = [e for e in eval_set if not e["should_trigger"]]
# Shuffle each group
random.shuffle(trigger)
random.shuffle(no_trigger)
# Calculate split points
n_trigger_test = max(1, int(len(trigger) * holdout))
n_no_trigger_test = max(1, int(len(no_trigger) * holdout))
# Split
test_set = trigger[:n_trigger_test] + no_trigger[:n_no_trigger_test]
train_set = trigger[n_trigger_test:] + no_trigger[n_no_trigger_test:]
return train_set, test_set
def run_loop(
eval_set: list[dict],
skill_path: Path,
description_override: str | None,
num_workers: int,
timeout: int,
max_iterations: int,
runs_per_query: int,
trigger_threshold: float,
holdout: float,
model: str,
verbose: bool,
live_report_path: Path | None = None,
log_dir: Path | None = None,
) -> dict:
"""Run the eval + improvement loop."""
project_root = find_project_root()
name, original_description, content = parse_skill_md(skill_path)
current_description = description_override or original_description
# Split into train/test if holdout > 0
if holdout > 0:
train_set, test_set = split_eval_set(eval_set, holdout)
if verbose:
print(f"Split: {len(train_set)} train, {len(test_set)} test (holdout={holdout})", file=sys.stderr)
else:
train_set = eval_set
test_set = []
client = anthropic.Anthropic()
history = []
exit_reason = "unknown"
for iteration in range(1, max_iterations + 1):
if verbose:
print(f"\n{'='*60}", file=sys.stderr)
print(f"Iteration {iteration}/{max_iterations}", file=sys.stderr)
print(f"Description: {current_description}", file=sys.stderr)
print(f"{'='*60}", file=sys.stderr)
# Evaluate train + test together in one batch for parallelism
all_queries = train_set + test_set
t0 = time.time()
all_results = run_eval(
eval_set=all_queries,
skill_name=name,
description=current_description,
num_workers=num_workers,
timeout=timeout,
project_root=project_root,
runs_per_query=runs_per_query,
trigger_threshold=trigger_threshold,
model=model,
)
eval_elapsed = time.time() - t0
# Split results back into train/test by matching queries
train_queries_set = {q["query"] for q in train_set}
train_result_list = [r for r in all_results["results"] if r["query"] in train_queries_set]
test_result_list = [r for r in all_results["results"] if r["query"] not in train_queries_set]
train_passed = sum(1 for r in train_result_list if r["pass"])
train_total = len(train_result_list)
train_summary = {"passed": train_passed, "failed": train_total - train_passed, "total": train_total}
train_results = {"results": train_result_list, "summary": train_summary}
if test_set:
test_passed = sum(1 for r in test_result_list if r["pass"])
test_total = len(test_result_list)
test_summary = {"passed": test_passed, "failed": test_total - test_passed, "total": test_total}
test_results = {"results": test_result_list, "summary": test_summary}
else:
test_results = None
test_summary = None
history.append({
"iteration": iteration,
"description": current_description,
"train_passed": train_summary["passed"],
"train_failed": train_summary["failed"],
"train_total": train_summary["total"],
"train_results": train_results["results"],
"test_passed": test_summary["passed"] if test_summary else None,
"test_failed": test_summary["failed"] if test_summary else None,
"test_total": test_summary["total"] if test_summary else None,
"test_results": test_results["results"] if test_results else None,
# For backward compat with report generator
"passed": train_summary["passed"],
"failed": train_summary["failed"],
"total": train_summary["total"],
"results": train_results["results"],
})
# Write live report if path provided
if live_report_path:
partial_output = {
"original_description": original_description,
"best_description": current_description,
"best_score": "in progress",
"iterations_run": len(history),
"holdout": holdout,
"train_size": len(train_set),
"test_size": len(test_set),
"history": history,
}
live_report_path.write_text(generate_html(partial_output, auto_refresh=True, skill_name=name))
if verbose:
def print_eval_stats(label, results, elapsed):
pos = [r for r in results if r["should_trigger"]]
neg = [r for r in results if not r["should_trigger"]]
tp = sum(r["triggers"] for r in pos)
pos_runs = sum(r["runs"] for r in pos)
fn = pos_runs - tp
fp = sum(r["triggers"] for r in neg)
neg_runs = sum(r["runs"] for r in neg)
tn = neg_runs - fp
total = tp + tn + fp + fn
precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0
accuracy = (tp + tn) / total if total > 0 else 0.0
print(f"{label}: {tp+tn}/{total} correct, precision={precision:.0%} recall={recall:.0%} accuracy={accuracy:.0%} ({elapsed:.1f}s)", file=sys.stderr)
for r in results:
status = "PASS" if r["pass"] else "FAIL"
rate_str = f"{r['triggers']}/{r['runs']}"
print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:60]}", file=sys.stderr)
print_eval_stats("Train", train_results["results"], eval_elapsed)
if test_summary:
print_eval_stats("Test ", test_results["results"], 0)
if train_summary["failed"] == 0:
exit_reason = f"all_passed (iteration {iteration})"
if verbose:
print(f"\nAll train queries passed on iteration {iteration}!", file=sys.stderr)
break
if iteration == max_iterations:
exit_reason = f"max_iterations ({max_iterations})"
if verbose:
print(f"\nMax iterations reached ({max_iterations}).", file=sys.stderr)
break
# Improve the description based on train results
if verbose:
print(f"\nImproving description...", file=sys.stderr)
t0 = time.time()
# Strip test scores from history so improvement model can't see them
blinded_history = [
{k: v for k, v in h.items() if not k.startswith("test_")}
for h in history
]
new_description = improve_description(
client=client,
skill_name=name,
skill_content=content,
current_description=current_description,
eval_results=train_results,
history=blinded_history,
model=model,
log_dir=log_dir,
iteration=iteration,
)
improve_elapsed = time.time() - t0
if verbose:
print(f"Proposed ({improve_elapsed:.1f}s): {new_description}", file=sys.stderr)
current_description = new_description
# Find the best iteration by TEST score (or train if no test set)
if test_set:
best = max(history, key=lambda h: h["test_passed"] or 0)
best_score = f"{best['test_passed']}/{best['test_total']}"
else:
best = max(history, key=lambda h: h["train_passed"])
best_score = f"{best['train_passed']}/{best['train_total']}"
if verbose:
print(f"\nExit reason: {exit_reason}", file=sys.stderr)
print(f"Best score: {best_score} (iteration {best['iteration']})", file=sys.stderr)
return {
"exit_reason": exit_reason,
"original_description": original_description,
"best_description": best["description"],
"best_score": best_score,
"best_train_score": f"{best['train_passed']}/{best['train_total']}",
"best_test_score": f"{best['test_passed']}/{best['test_total']}" if test_set else None,
"final_description": current_description,
"iterations_run": len(history),
"holdout": holdout,
"train_size": len(train_set),
"test_size": len(test_set),
"history": history,
}
def main():
parser = argparse.ArgumentParser(description="Run eval + improve loop")
parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file")
parser.add_argument("--skill-path", required=True, help="Path to skill directory")
parser.add_argument("--description", default=None, help="Override starting description")
parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers")
parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds")
parser.add_argument("--max-iterations", type=int, default=5, help="Max improvement iterations")
parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query")
parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold")
parser.add_argument("--holdout", type=float, default=0.4, help="Fraction of eval set to hold out for testing (0 to disable)")
parser.add_argument("--model", required=True, help="Model for improvement")
parser.add_argument("--verbose", action="store_true", help="Print progress to stderr")
parser.add_argument("--report", default="auto", help="Generate HTML report at this path (default: 'auto' for temp file, 'none' to disable)")
parser.add_argument("--results-dir", default=None, help="Save all outputs (results.json, report.html, log.txt) to a timestamped subdirectory here")
args = parser.parse_args()
eval_set = json.loads(Path(args.eval_set).read_text())
skill_path = Path(args.skill_path)
if not (skill_path / "SKILL.md").exists():
print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr)
sys.exit(1)
name, _, _ = parse_skill_md(skill_path)
# Set up live report path
if args.report != "none":
if args.report == "auto":
timestamp = time.strftime("%Y%m%d_%H%M%S")
live_report_path = Path(tempfile.gettempdir()) / f"skill_description_report_{skill_path.name}_{timestamp}.html"
else:
live_report_path = Path(args.report)
# Open the report immediately so the user can watch
live_report_path.write_text("<html><body><h1>Starting optimization loop...</h1><meta http-equiv='refresh' content='5'></body></html>")
webbrowser.open(str(live_report_path))
else:
live_report_path = None
# Determine output directory (create before run_loop so logs can be written)
if args.results_dir:
timestamp = time.strftime("%Y-%m-%d_%H%M%S")
results_dir = Path(args.results_dir) / timestamp
results_dir.mkdir(parents=True, exist_ok=True)
else:
results_dir = None
log_dir = results_dir / "logs" if results_dir else None
output = run_loop(
eval_set=eval_set,
skill_path=skill_path,
description_override=args.description,
num_workers=args.num_workers,
timeout=args.timeout,
max_iterations=args.max_iterations,
runs_per_query=args.runs_per_query,
trigger_threshold=args.trigger_threshold,
holdout=args.holdout,
model=args.model,
verbose=args.verbose,
live_report_path=live_report_path,
log_dir=log_dir,
)
# Save JSON output
json_output = json.dumps(output, indent=2)
print(json_output)
if results_dir:
(results_dir / "results.json").write_text(json_output)
# Write final HTML report (without auto-refresh)
if live_report_path:
live_report_path.write_text(generate_html(output, auto_refresh=False, skill_name=name))
print(f"\nReport: {live_report_path}", file=sys.stderr)
if results_dir and live_report_path:
(results_dir / "report.html").write_text(generate_html(output, auto_refresh=False, skill_name=name))
if results_dir:
print(f"Results saved to: {results_dir}", file=sys.stderr)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,47 @@
"""Shared utilities for skill-creator scripts."""
from pathlib import Path
def parse_skill_md(skill_path: Path) -> tuple[str, str, str]:
"""Parse a SKILL.md file, returning (name, description, full_content)."""
content = (skill_path / "SKILL.md").read_text()
lines = content.split("\n")
if lines[0].strip() != "---":
raise ValueError("SKILL.md missing frontmatter (no opening ---)")
end_idx = None
for i, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
end_idx = i
break
if end_idx is None:
raise ValueError("SKILL.md missing frontmatter (no closing ---)")
name = ""
description = ""
frontmatter_lines = lines[1:end_idx]
i = 0
while i < len(frontmatter_lines):
line = frontmatter_lines[i]
if line.startswith("name:"):
name = line[len("name:"):].strip().strip('"').strip("'")
elif line.startswith("description:"):
value = line[len("description:"):].strip()
# Handle YAML multiline indicators (>, |, >-, |-)
if value in (">", "|", ">-", "|-"):
continuation_lines: list[str] = []
i += 1
while i < len(frontmatter_lines) and (frontmatter_lines[i].startswith(" ") or frontmatter_lines[i].startswith("\t")):
continuation_lines.append(frontmatter_lines[i].strip())
i += 1
description = " ".join(continuation_lines)
continue
else:
description = value.strip('"').strip("'")
i += 1
return name, description, content

View file

@ -0,0 +1,97 @@
---
name: writing-skills
description: "Use when creating, updating, or improving agent skills."
---
# Writing Skills (Excellence)
Dispatcher for skill creation excellence. Use the decision tree below to find the right template and standards.
## Quick Decision Tree
### What do you need to do?
1. **Create a NEW skill:**
- Is it simple (single file, <200 lines)? -> [Tier 1 Architecture](references/tier-1-simple/README.md)
- Is it complex (multi-concept, 200-1000 lines)? -> [Tier 2 Architecture](references/tier-2-expanded/README.md)
- Is it a massive platform (10+ products, AWS, Convex)? -> [Tier 3 Architecture](references/tier-3-platform/README.md)
2. **Improve an EXISTING skill:**
- Fix "it's too long" -> [Modularize (Tier 3)](references/templates/tier-3-platform.md)
- Fix "AI ignores rules" -> [Anti-Rationalization](references/anti-rationalization/README.md)
- Fix "users can't find it" -> [CSO (Search Optimization)](references/cso/README.md)
3. **Verify Compliance:**
- Check metadata/naming -> [Standards](references/standards/README.md)
- Add tests -> [Testing Guide](references/testing/README.md)
## Component Index
| Component | Purpose |
|-----------|---------|
| **[CSO](references/cso/README.md)** | "SEO for LLMs". How to write descriptions that trigger. |
| **[Standards](references/standards/README.md)** | File naming, YAML frontmatter, directory structure. |
| **[Anti-Rationalization](references/anti-rationalization/README.md)**| How to write rules that agents won't ignore. |
| **[Testing](references/testing/README.md)** | How to ensure your skill actually works. |
## Templates
- [Technique Skill](references/templates/technique.md) (How-to)
- [Reference Skill](references/templates/reference.md) (Docs)
- [Discipline Skill](references/templates/discipline.md) (Rules)
- [Pattern Skill](references/templates/pattern.md) (Design Patterns)
## When to Use
- Creating a NEW skill from scratch
- Improving an EXISTING skill that agents ignore
- Debugging why a skill isn't being triggered
- Standardizing skills across a team
## How It Works
1. **Identify goal** -> Use decision tree above
2. **Select template** -> From `references/templates/`
3. **Apply CSO** -> Optimize description for discovery
4. **Add anti-rationalization** -> For discipline skills
5. **Test** -> RED-GREEN-REFACTOR cycle
## Quick Example
```yaml
---
name: my-technique
description: Use when [specific symptom occurs].
metadata:
category: technique
triggers: error-text, symptom, tool-name
---
# My Technique
## When to Use
- [Symptom A]
- [Error message]
```
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Description summarizes workflow | Use "Use when..." triggers only |
| No `metadata.triggers` | Add 3+ keywords |
| Generic name ("helper") | Use gerund (`creating-skills`) |
| Long monolithic SKILL.md | Split into `references/` |
See [gotchas.md](gotchas.md) for more.
## Pre-Deploy Checklist
Before deploying any skill:
- [ ] `name` field matches directory name exactly
- [ ] `SKILL.md` filename is ALL CAPS
- [ ] Description starts with "Use when..."
- [ ] `metadata.triggers` has 3+ keywords
- [ ] Total lines < 500 (use `references/` for more)
- [ ] No `@` force-loading in cross-references
- [ ] Tested with real scenarios

View file

@ -0,0 +1,236 @@
# Skill Templates & Examples
Complete, copy-paste templates for each skill type.
---
## Template: Technique Skill
For how-to guides that teach a specific method.
```markdown
---
name: technique-name
description: >-
Use when [specific symptom].
metadata:
category: technique
triggers: error-text, symptom, tool-name
---
# Technique Name
## Overview
[1-2 sentence core principle]
## When to Use
- [Symptom A]
- [Symptom B]
- [Error message text]
**NOT for:**
- [When to avoid]
## The Problem
\`\`\`javascript
// Bad example
function badCode() {
// problematic pattern
}
\`\`\`
## The Solution
\`\`\`javascript
// Good example
function goodCode() {
// improved pattern
}
\`\`\`
## Step-by-Step
1. [First step]
2. [Second step]
3. [Final step]
## Quick Reference
| Scenario | Approach |
|----------|----------|
| Case A | Solution A |
| Case B | Solution B |
## Common Mistakes
**Mistake 1:** [Description]
- Wrong: \`bad code\`
- Right: \`good code\`
```
---
## Template: Reference Skill
For documentation, APIs, and lookup tables.
```markdown
---
name: reference-name
description: >-
Use when working with [domain].
metadata:
category: reference
triggers: tool, api, specific-terms
---
# Reference Name
## Quick Reference
| Command | Purpose |
|---------|---------|
| \`cmd1\` | Does X |
| \`cmd2\` | Does Y |
## Common Patterns
**Pattern A:**
\`\`\`bash
example command
\`\`\`
**Pattern B:**
\`\`\`bash
another example
\`\`\`
## Detailed Docs
For more options, run \`--help\` or see:
- patterns.md
- [examples.md](examples.md)
```
---
## Template: Discipline Skill
For rules that agents must follow. Requires anti-rationalization techniques.
```markdown
---
name: discipline-name
description: >-
Use when [BEFORE violation].
metadata:
category: discipline
triggers: new feature, code change, implementation
---
# Rule Name
## Iron Law
**[SINGLE SENTENCE ABSOLUTE RULE]**
Violating the letter IS violating the spirit.
## The Rule
1. ALWAYS [step 1]
2. NEVER [step 2]
3. [Step 3]
## Violations
[Action before rule]? **Delete it. Start over.**
**No exceptions:**
- Don't keep it as "reference"
- Don't "adapt" it
- Delete means delete
## Common Rationalizations
| Excuse | Reality |
|--------|---------|
| "Too simple" | Simple code breaks. Rule takes 30 seconds. |
| "I'll do it after" | After = never. Do it now. |
| "Spirit not ritual" | The ritual IS the spirit. |
## Red Flags - STOP
- [Flag 1]
- [Flag 2]
- "This is different because..."
**All mean:** Delete. Start over.
## Valid Exceptions
- [Exception 1]
- [Exception 2]
**Everything else:** Follow the rule.
```
---
## Template: Pattern Skill
For mental models and design patterns.
```markdown
---
name: pattern-name
description: >-
Use when [recognizable symptom].
metadata:
category: pattern
triggers: complexity, hard-to-follow, nested
---
# Pattern Name
## The Pattern
[1-2 sentence core idea]
## Recognition Signs
- [Sign that pattern applies]
- [Another sign]
- [Code smell]
## Before
\`\`\`typescript
// Complex/problematic
function before() {
// nested, confusing
}
\`\`\`
## After
\`\`\`typescript
// Clean/improved
function after() {
// flat, clear
}
\`\`\`
## When NOT to Use
- [Over-engineering case]
- [Simple case that doesn't need it]
## Impact
**Before:** [Problem metric]
**After:** [Improved metric]
```

View file

@ -0,0 +1,175 @@
---
description: Common pitfalls and tribal knowledge for skill creation.
metadata:
tags: [gotchas, troubleshooting, mistakes]
---
# Skill Writing Gotchas
Tribal knowledge to avoid common mistakes.
## YAML Frontmatter
### Invalid Syntax
```yaml
# BAD: Mixed list and map
metadata:
references:
triggers: a, b, c
- item1
- item2
# GOOD: Consistent structure
metadata:
triggers: a, b, c
references:
- item1
- item2
```
### Multiline Description
```yaml
# BAD: Line breaks create parsing errors
description: Use when creating skills.
Also for updating.
# GOOD: Use YAML multiline syntax
description: >-
Use when creating or updating skills.
Triggers: new skill, update skill
```
## Naming
### Directory Must Match `name` Field
```
# BAD
directory: my-skill/
name: mySkill # Mismatch!
# GOOD
directory: my-skill/
name: my-skill # Exact match
```
### SKILL.md Must Be ALL CAPS
```
# BAD
skill.md
Skill.md
# GOOD
SKILL.md
```
## Discovery
### Description = Triggers, NOT Workflow
```yaml
# BAD: Agent reads this and skips the full skill
description: Analyzes code, finds bugs, suggests fixes
# GOOD: Agent reads full skill to understand workflow
description: Use when debugging errors or reviewing code quality
```
### Pre-Violation Triggers for Discipline Skills
```yaml
# BAD: Triggers AFTER violation
description: Use when you forgot to write tests
# GOOD: Triggers BEFORE violation
description: Use when implementing any feature, before writing code
```
## Token Efficiency
### Skill Loaded Every Conversation = Token Drain
- Frequently-loaded skills: <200 words
- All others: <500 words
- Move details to `references/` files
### Don't Duplicate CLI Help
```markdown
# BAD: 50 lines documenting all flags
# GOOD: One line
Run `mytool --help` for all options.
```
## Anti-Rationalization (Discipline Skills Only)
### Agents Are Smart at Finding Loopholes
```markdown
# BAD: Trust agents will "get the spirit"
Write test before code.
# GOOD: Close every loophole explicitly
Write test before code.
**No exceptions:**
- Don't keep code as "reference"
- Don't "adapt" existing code
- Delete means delete
```
### Build Rationalization Table
Every excuse from baseline testing goes in the table:
| Excuse | Reality |
|--------|---------|
| "Too simple to test" | Simple code breaks. Test takes 30 seconds. |
| "I'll test after" | Tests-after prove nothing immediately. |
## Cross-References
### Keep References One Level Deep
```markdown
# BAD: Nested chain (A -> B -> C)
See [patterns.md] -> which links to [advanced.md] -> which links to [deep.md]
# GOOD: Flat (A -> B, A -> C)
See [patterns.md] and [advanced.md]
```
### Never Force-Load with @
```markdown
# BAD: Burns context immediately
@skills/my-skill/SKILL.md
# GOOD: Agent loads when needed
See [my-skill] for details.
```
## Tier Selection
### Don't Overthink Tier Choice
```markdown
# BAD: Starting with Tier 3 "just in case"
# Result: Wasted effort, empty reference files
# GOOD: Start with Tier 1, upgrade when needed
# Can always add references/ later
```
### Signals You Need to Upgrade
| Signal | Action |
|--------|--------|
| SKILL.md > 200 lines | -> Tier 2 |
| 3+ related sub-topics | -> Tier 2 |
| 10+ products/services | -> Tier 3 |
| "I need X" vs "I want Y" | -> Tier 3 decision trees |

View file

@ -0,0 +1,86 @@
# Persuasion Principles for Skill Design
## Overview
LLMs respond to the same persuasion principles as humans. Understanding this psychology helps you design more effective skills - not to manipulate, but to ensure critical practices are followed even under pressure.
**Research foundation:** Meincke et al. (2025) tested 7 persuasion principles with N=28,000 AI conversations. Persuasion techniques more than doubled compliance rates (33% to 72%, p < .001).
## The Seven Principles
### 1. Authority
**What it is:** Deference to expertise, credentials, or official sources.
**How it works in skills:**
- Imperative language: "YOU MUST", "Never", "Always"
- Non-negotiable framing: "No exceptions"
- Eliminates decision fatigue and rationalization
**When to use:**
- Discipline-enforcing skills (TDD, verification requirements)
- Safety-critical practices
- Established best practices
### 2. Commitment
**What it is:** Consistency with prior actions, statements, or public declarations.
**How it works in skills:**
- Require announcements: "Announce skill usage"
- Force explicit choices: "Choose A, B, or C"
- Use tracking: TodoWrite for checklists
### 3. Scarcity
**What it is:** Urgency from time limits or limited availability.
**How it works in skills:**
- Time-bound requirements: "Before proceeding"
- Sequential dependencies: "Immediately after X"
- Prevents procrastination
### 4. Social Proof
**What it is:** Conformity to what others do or what's considered normal.
**How it works in skills:**
- Universal patterns: "Every time", "Always"
- Failure modes: "X without Y = failure"
- Establishes norms
### 5. Unity
**What it is:** Shared identity, "we-ness", in-group belonging.
**How it works in skills:**
- Collaborative language: "our codebase", "we're colleagues"
- Shared goals: "we both want quality"
### 6. Reciprocity
**What it is:** Obligation to return benefits received.
- Use sparingly - can feel manipulative
- Rarely needed in skills
### 7. Liking
**What it is:** Preference for cooperating with those we like.
- **DON'T USE for compliance**
- Conflicts with honest feedback culture
## Principle Combinations by Skill Type
| Skill Type | Use | Avoid |
|------------|-----|-------|
| Discipline-enforcing | Authority + Commitment + Social Proof | Liking, Reciprocity |
| Guidance/technique | Moderate Authority + Unity | Heavy authority |
| Collaborative | Unity + Commitment | Authority, Liking |
| Reference | Clarity only | All persuasion |
## Ethical Use
**Legitimate:**
- Ensuring critical practices are followed
- Creating effective documentation
- Preventing predictable failures
**The test:** Would this technique serve the user's genuine interests if they fully understood it?
## Research Citations
**Cialdini, R. B. (2021).** *Influence: The Psychology of Persuasion (New and Expanded).* Harper Business.
**Meincke, L., et al. (2025).** Call Me A Jerk: Persuading AI to Comply with Objectionable Requests. University of Pennsylvania.

View file

@ -0,0 +1,85 @@
# Anti-Rationalization Guide
Techniques for bulletproofing skills against agent rationalization.
## The Problem
Discipline-enforcing skills face a unique challenge: smart agents under pressure will find loopholes.
## Technique 1: Close Every Loophole Explicitly
Don't just state the rule - forbid specific workarounds.
### Bad Example
```markdown
Write code before test? Delete it.
```
### Good Example
```markdown
Write code before test? Delete it. Start over.
**No exceptions**:
- Don't keep it as "reference"
- Don't "adapt" it while writing tests
- Don't look at it
- Delete means delete
```
## Technique 2: Address "Spirit vs Letter" Arguments
Add foundational principle early:
```markdown
**Violating the letter of the rules is violating the spirit of the rules.**
```
## Technique 3: Build Rationalization Table
| Excuse | Reality |
|--------|---------|
| "Too simple to test" | Simple code breaks. Test takes 30 seconds. |
| "I'll test after" | Tests passing immediately prove nothing. |
| "Spirit not ritual" | The letter IS the spirit. |
## Technique 4: Create Red Flags List
```markdown
## Red Flags - STOP and Start Over
- Code before test
- "I already manually tested it"
- "This is different because..."
**All of these mean**: Delete code. Start over.
```
## Technique 5: Use Strong Language
```markdown
# Weak (invites rationalization)
You should write tests first.
# Strong (no wiggle room)
ALWAYS write test first.
NEVER write code before test.
```
## Technique 6: Provide Escape Hatch for Legitimate Cases
```markdown
## When NOT to Use
- Spike solutions (throwaway exploratory code)
- One-time scripts deleting in 1 hour
**Everything else**: Follow the rule. No exceptions.
```
## Complete Bulletproofing Checklist
- [ ] Forbidden each specific workaround explicitly?
- [ ] Added "spirit vs letter" principle?
- [ ] Built rationalization table from baseline tests?
- [ ] Created red flags list?
- [ ] Used strong language (ALWAYS/NEVER)?
- [ ] Provided explicit escape hatch?
- [ ] Description includes pre-violation symptoms?

View file

@ -0,0 +1,90 @@
# CSO Guide - Claude Search Optimization
Advanced techniques for making skills discoverable by agents.
## The Discovery Problem
You have 100+ skills. Agent receives a task. How does it find the RIGHT skill?
**Answer**: The `description` field.
## Critical Rule: Description = Triggers, NOT Workflow
### The Trap
When description summarizes workflow, agents take a shortcut.
**Real example that failed**:
```yaml
# Agent did ONE review instead of TWO
description: Code review between tasks
# Skill body had flowchart showing TWO reviews
```
**Why it failed**: Agent read description, thought "code review between tasks means one review", never read the flowchart.
**Fix**:
```yaml
# Agent now reads full skill and follows flowchart
description: Use when executing implementation plans with independent tasks
```
### The Pattern
```yaml
# BAD: Workflow summary
description: Analyzes git diff, generates commit message in conventional format
# GOOD: Trigger conditions only
description: Use when generating commit messages or reviewing staged changes
```
## Token Efficiency
**Target word counts**:
- Frequently-loaded skills: <200 words total
- Other skills: <500 words
## Keyword Strategy
### Error Messages
Include EXACT error text users will see.
### Symptoms
Use words users naturally say: "flaky", "hangs", "slow", "timeout", "race condition"
### Tools & Commands
Actual names, not descriptions: "pytest", not "Python testing"
### Synonyms
Cover multiple ways to describe same thing: timeout/hang/freeze
## Description Template
```yaml
description: "Use when [SPECIFIC TRIGGER]."
metadata:
triggers: [error1], [symptom2], [tool3]
```
## Third Person Rule
```yaml
# BAD: First person
description: "I can help you with async tests"
# GOOD: Third person
description: "Handles async tests with race conditions"
```
## Verification Checklist
- [ ] Description starts with "Use when..."?
- [ ] Description is <500 characters?
- [ ] Description lists ONLY triggers, not workflow?
- [ ] Includes 3+ keywords (errors/symptoms/tools)?
- [ ] Third person throughout?
- [ ] Name uses gerund or verb-first format?

View file

@ -0,0 +1,87 @@
---
description: Standards and naming rules for creating agent skills.
metadata:
tags: [standards, naming, yaml, structure]
---
# Skill Development Guide
## Directory Structure
```
skills/
{skill-name}/ # kebab-case, matches `name` field
SKILL.md # Required: main skill definition
references/ # Optional: supporting documentation
README.md # Sub-topic entry point
*.md # Additional files
```
## Naming Rules
| Element | Rule | Example |
|---------|------|---------|
| Directory | kebab-case, 1-64 chars | `react-best-practices` |
| `SKILL.md` | ALL CAPS, exact filename | `SKILL.md` (not `skill.md`) |
| `name` field | Must match directory name | `name: react-best-practices` |
## SKILL.md Structure
```markdown
---
name: {skill-name}
description: >-
Use when [trigger condition].
metadata:
category: technique
triggers: keyword1, keyword2, error-text
---
# Skill Title
Brief description of what this skill does.
## When to Use
- Symptom or situation A
- Symptom or situation B
## How It Works
Step-by-step instructions or reference content.
## Examples
Concrete usage examples.
## Common Mistakes
What to avoid and why.
```
## Description Best Practices
```yaml
# BAD: Workflow summary
description: Analyzes code, finds bugs, suggests fixes
# GOOD: Trigger conditions only
description: Use when debugging errors or reviewing code quality.
```
**Rules:**
- Start with "Use when..."
- Keep under 500 characters
- Use third person
## Context Efficiency
| Guideline | Reason |
|-----------|--------|
| Keep SKILL.md < 500 lines | Reduces context consumption |
| Put details in supporting files | Agent reads only what's needed |
| Use tables for reference data | More compact than prose |
## Verification Checklist
- [ ] `name` matches directory name?
- [ ] `SKILL.md` is ALL CAPS?
- [ ] Description starts with "Use when..."?
- [ ] Under 500 lines?
- [ ] Tested with real scenarios?

View file

@ -0,0 +1,39 @@
# SKILL.md Metadata Standard
Official frontmatter fields.
## Required Fields
```yaml
---
name: skill-name
description: >-
Use when [trigger condition].
---
```
| Field | Rules |
|-------|-------|
| `name` | 1-64 chars, lowercase, hyphens only, must match directory name |
| `description` | 1-1024 chars, should describe when to use |
## Optional Fields
```yaml
---
name: skill-name
description: Purpose and triggers.
metadata:
category: "reference"
version: "1.0.0"
---
```
## Name Validation
```regex
^[a-z0-9]+(-[a-z0-9]+)*$
```
**Valid**: `my-skill`, `git-release`, `tdd`
**Invalid**: `My-Skill`, `my_skill`, `-my-skill`

View file

@ -0,0 +1,54 @@
---
name: discipline-name
description: >-
Use when [BEFORE violation].
metadata:
category: discipline
triggers: new feature, code change, implementation
---
# Rule Name
## Iron Law
**[SINGLE SENTENCE ABSOLUTE RULE]**
Violating the letter IS violating the spirit.
## The Rule
1. ALWAYS [step 1]
2. NEVER [step 2]
3. [Step 3]
## Violations
[Action before rule]? **Delete it. Start over.**
**No exceptions:**
- Don't keep it as "reference"
- Don't "adapt" it
- Delete means delete
## Common Rationalizations
| Excuse | Reality |
|--------|---------|
| "Too simple" | Simple code breaks. Rule takes 30 seconds. |
| "I'll do it after" | After = never. Do it now. |
| "Spirit not ritual" | The ritual IS the spirit. |
## Red Flags - STOP
- [Flag 1]
- [Flag 2]
- "This is different because..."
**All mean:** Delete. Start over.
## Valid Exceptions
- [Exception 1]
- [Exception 2]
**Everything else:** Follow the rule.

View file

@ -0,0 +1,48 @@
---
name: pattern-name
description: >-
Use when [recognizable symptom].
metadata:
category: pattern
triggers: complexity, hard-to-follow, nested
---
# Pattern Name
## The Pattern
[1-2 sentence core idea]
## Recognition Signs
- [Sign that pattern applies]
- [Another sign]
- [Code smell]
## Before
```typescript
// Complex/problematic
function before() {
// nested, confusing
}
```
## After
```typescript
// Clean/improved
function after() {
// flat, clear
}
```
## When NOT to Use
- [Over-engineering case]
- [Simple case that doesn't need it]
## Impact
**Before:** [Problem metric]
**After:** [Improved metric]

View file

@ -0,0 +1,35 @@
---
name: reference-name
description: >-
Use when working with [domain].
metadata:
category: reference
triggers: tool, api, specific-terms
---
# Reference Name
## Quick Reference
| Command | Purpose |
|---------|---------|
| `cmd1` | Does X |
| `cmd2` | Does Y |
## Common Patterns
**Pattern A:**
```bash
example command
```
**Pattern B:**
```bash
another example
```
## Detailed Docs
For more options, run `--help` or see:
- patterns.md
- examples.md

View file

@ -0,0 +1,59 @@
---
name: technique-name
description: Use when [specific symptom].
metadata:
category: technique
triggers: error-text, symptom, tool-name
---
# Technique Name
## Overview
[1-2 sentence core principle]
## When to Use
- [Symptom A]
- [Symptom B]
- [Error message text]
**NOT for:**
- [When to avoid]
## The Problem
```javascript
// Bad example
function badCode() {
// problematic pattern
}
```
## The Solution
```javascript
// Good example
function goodCode() {
// improved pattern
}
```
## Step-by-Step
1. [First step]
2. [Second step]
3. [Final step]
## Quick Reference
| Scenario | Approach |
|----------|----------|
| Case A | Solution A |
| Case B | Solution B |
## Common Mistakes
**Mistake 1:** [Description]
- Wrong: `bad code`
- Right: `good code`

View file

@ -0,0 +1,17 @@
# Platform Name Skill
Template for complex Tier 3 skills.
## Structure
```
skill/
SKILL.md # Dispatcher
references/
topic/
README.md # Overview
api.md # API Reference
config.md # Configuration
patterns.md # Recipes
gotchas.md # Critical Errors
```

View file

@ -0,0 +1,66 @@
# Testing Guide - TDD for Skills
Complete methodology for testing skills using RED-GREEN-REFACTOR cycle.
## Testing All Skill Types
### Discipline-Enforcing Skills (rules/requirements)
**Test with**:
- Academic questions: Do they understand the rules?
- Pressure scenarios: Do they comply under stress?
- Multiple pressures combined: time + sunk cost + exhaustion
**Success criteria**: Agent follows rule under maximum pressure
### Technique Skills (how-to guides)
**Test with**:
- Application scenarios: Can they apply the technique correctly?
- Variation scenarios: Do they handle edge cases?
- Missing information tests: Do instructions have gaps?
**Success criteria**: Agent successfully applies technique to new scenario
### Pattern Skills (mental models)
**Test with**:
- Recognition scenarios: Do they recognize when pattern applies?
- Counter-examples: Do they know when NOT to apply?
**Success criteria**: Agent correctly identifies when/how to apply pattern
### Reference Skills (documentation/APIs)
**Test with**:
- Retrieval scenarios: Can they find the right information?
- Gap testing: Are common use cases covered?
**Success criteria**: Agent finds and correctly applies reference information
## Pressure Types for Testing
| Pressure | Example |
|----------|---------|
| Time | "You have 5 minutes to complete this task" |
| Sunk cost | "You already spent 2 hours on this" |
| Authority | "Senior developer said to skip tests" |
| Exhaustion | "This is the 10th task today" |
## Complete Test Checklist
**Baseline (RED)**:
- [ ] Designed 3+ pressure scenarios
- [ ] Ran scenarios WITHOUT skill
- [ ] Documented verbatim agent responses
**Implementation (GREEN)**:
- [ ] Skill addresses SPECIFIC baseline failures
- [ ] Re-ran scenarios WITH skill
- [ ] Agent complied in all scenarios
**Bulletproofing (REFACTOR)**:
- [ ] Tested with combined pressures
- [ ] Found and documented new rationalizations
- [ ] Added explicit counters
- [ ] Re-tested until no more loopholes

View file

@ -0,0 +1,30 @@
---
description: When to use Tier 1 (Simple) skill architecture.
metadata:
tags: [tier-1, simple, single-file]
---
# Tier 1: Simple Skills
Single-file skills for focused, specific purposes.
## When to Use
- **Single concept**: One technique, one pattern, one reference
- **Under 200 lines**: Can fit comfortably in one file
- **No complex decision logic**: User knows exactly what they need
- **Frequently loaded**: Needs minimal token footprint
## Structure
```
my-skill/
SKILL.md # Everything in one file
```
## Checklist
- [ ] Fits in <200 lines
- [ ] Single focused purpose
- [ ] No need for `references/` directory
- [ ] Description uses "Use when..." pattern

View file

@ -0,0 +1,52 @@
---
description: When to use Tier 2 (Expanded) skill architecture.
metadata:
tags: [tier-2, expanded, multi-file]
---
# Tier 2: Expanded Skills
Multi-file skills for complex topics with multiple sub-concepts.
## When to Use
- **Multiple related concepts**: Needs separation of concerns
- **200-1000 lines total**: Too big for one file
- **Needs reference files**: Patterns, examples, troubleshooting
- **Cross-linking**: Users need to navigate between sub-topics
## Structure
```
my-skill/
SKILL.md # Overview + navigation
references/
core/
README.md # Main concept
patterns/
README.md # Usage patterns
troubleshooting/
README.md # Common issues
```
## Progressive Disclosure
1. **Metadata** (~100 tokens): Name + description loaded at startup
2. **SKILL.md** (<500 lines): Decision tree + index
3. **References** (as needed): Loaded only when user navigates
## Key Differences from Tier 1
| Aspect | Tier 1 | Tier 2 |
|--------|--------|--------|
| Files | 1 | 5-20 |
| Total lines | <200 | 200-1000 |
| Decision logic | None | Simple tree |
| Token cost | Minimal | Medium (progressive) |
## Checklist
- [ ] SKILL.md has clear navigation links
- [ ] Each `references/` subdir has README.md
- [ ] No circular references between files
- [ ] Decision tree points to specific files

View file

@ -0,0 +1,51 @@
---
description: When to use Tier 3 (Platform) skill architecture for large platforms.
metadata:
tags: [tier-3, platform, enterprise]
---
# Tier 3: Platform Skills
Enterprise-grade skills for entire platforms (AWS, Cloudflare, Convex, etc).
## When to Use
- **Entire platform**: 10+ products/services
- **1000+ lines total**: Would overwhelm context if monolithic
- **Complex decision logic**: Users start with "I need X" not "I want product Y"
## The 5-File Pattern
Each product directory has exactly 5 files:
| File | Purpose | When to Load |
|------|---------|--------------|
| `README.md` | Overview, when to use | Always first |
| `api.md` | Runtime APIs, methods | Implementing features |
| `configuration.md` | Config, environment | Setting up |
| `patterns.md` | Common workflows | Best practices |
| `gotchas.md` | Pitfalls, limits | Debugging |
## Decision Trees
```markdown
Need to store data?
Simple key-value -> kv/
Relational queries -> d1/
Large files/blobs -> r2/
Per-user state -> durable-objects/
```
## Progressive Disclosure in Action
- **Startup**: Only name + description (~100 tokens)
- **Activation**: SKILL.md with trees (<5000 tokens)
- **Navigation**: One product's 5 files (as needed)
## Checklist
- [ ] SKILL.md contains ONLY decision trees + index
- [ ] Each product has exactly 5 files
- [ ] Decision trees cover all "I need X" scenarios
- [ ] Cross-references stay one level deep
- [ ] Every product has `gotchas.md`

View file

@ -0,0 +1,85 @@
# Testing Skills With Subagents
**Load this reference when:** creating or editing skills, before deployment, to verify they work under pressure and resist rationalization.
## Overview
**Testing skills is just TDD applied to process documentation.**
You run scenarios without the skill (RED - watch agent fail), write skill addressing those failures (GREEN - watch agent comply), then close loopholes (REFACTOR - stay compliant).
**Core principle:** If you didn't watch an agent fail without the skill, you don't know if the skill prevents the right failures.
## When to Use
Test skills that:
- Enforce discipline (TDD, testing requirements)
- Have compliance costs (time, effort, rework)
- Could be rationalized away ("just this once")
- Contradict immediate goals (speed over quality)
Don't test:
- Pure reference skills (API docs, syntax guides)
- Skills without rules to violate
- Skills agents have no incentive to bypass
## TDD Mapping for Skill Testing
| TDD Phase | Skill Testing | What You Do |
|-----------|---------------|-------------|
| **RED** | Baseline test | Run scenario WITHOUT skill, watch agent fail |
| **Verify RED** | Capture rationalizations | Document exact failures verbatim |
| **GREEN** | Write skill | Address specific baseline failures |
| **Verify GREEN** | Pressure test | Run scenario WITH skill, verify compliance |
| **REFACTOR** | Plug holes | Find new rationalizations, add counters |
| **Stay GREEN** | Re-verify | Test again, ensure still compliant |
## RED Phase: Baseline Testing (Watch It Fail)
**Goal:** Run test WITHOUT the skill - watch agent fail, document exact failures.
**Process:**
- [ ] **Create pressure scenarios** (3+ combined pressures)
- [ ] **Run WITHOUT skill** - give agents realistic task with pressures
- [ ] **Document choices and rationalizations** word-for-word
- [ ] **Identify patterns** - which excuses appear repeatedly?
- [ ] **Note effective pressures** - which scenarios trigger violations?
## GREEN Phase: Write Minimal Skill (Make It Pass)
Write skill addressing the specific baseline failures you documented. Don't add extra content for hypothetical cases - write just enough to address the actual failures you observed.
Run same scenarios WITH skill. Agent should now comply.
If agent still fails: skill is unclear or incomplete. Revise and re-test.
## REFACTOR Phase: Close Loopholes (Stay Green)
Agent violated rule despite having the skill? Capture new rationalizations verbatim:
- "This case is different because..."
- "I'm following the spirit not the letter"
- "Being pragmatic means adapting"
- "Deleting X hours is wasteful"
**Document every excuse.** These become your rationalization table.
## Testing Checklist (TDD for Skills)
**RED Phase:**
- [ ] Created pressure scenarios (3+ combined pressures)
- [ ] Ran scenarios WITHOUT skill (baseline)
- [ ] Documented agent failures and rationalizations verbatim
**GREEN Phase:**
- [ ] Wrote skill addressing specific baseline failures
- [ ] Ran scenarios WITH skill
- [ ] Agent now complies
**REFACTOR Phase:**
- [ ] Identified NEW rationalizations from testing
- [ ] Added explicit counters for each loophole
- [ ] Updated rationalization table
- [ ] Updated red flags list
- [ ] Re-tested - agent still complies
- [ ] Meta-tested to verify clarity
- [ ] Agent follows rule under maximum pressure

126
AGENTS.md Normal file
View file

@ -0,0 +1,126 @@
# AGENTS.md
## Project
- Minecraft Console Client (MCC) is a cross-platform text/TUI client for Minecraft Java Edition.
- Primary scope: connect to servers, send chat and commands, receive text, automate gameplay/admin tasks, and extend behavior through built-in bots or runtime C# scripts.
- Secondary scope: protocol/version adaptation tooling, docs site, legacy GUI wrapper, and debug tooling.
- Decompiled server source for both the old and new MC versions in `$MCC_REPO/MinecraftOfficial/<version>-decompiled/`
## Build / Run
- Init submodules first: `git submodule update --init --recursive`
- Build for local development: `source tools/mcc-env.sh && mcc-build`
- Publish (matches CI shape): `source tools/mcc-env.sh && mcc-publish --rid <RID>`
- Run/debug from source: `source tools/mcc-env.sh && mcc-debug -v 1.21.11 --file-input`
- Docs: `cd docs && npm install && npm run docs:dev` or `npm run docs:build`
- Docker: `cd Docker && docker build -t minecraft-console-client:latest .`
- Tests: no dedicated test project is present in the main solution.
- Current state: the solution builds after submodule init, but the underlying .NET build emits many analyzer and NuGet vulnerability warnings; treat them as real.
- Server roots: `tools/` helpers look for server jars under `MinecraftOfficial/downloads/<version>/` by default, but also support an external root via the `MCC_SERVERS` environment variable.
- Multi-version testing: tmux-based local server sessions are shared state. Run cross-version test matrices sequentially unless you have explicit per-version isolation. A server logging `Done` does not guarantee immediate RCON availability; retry RCON setup commands.
- Automated test configs: for repeated or matrix test runs, prefer generating a temporary MCC config per run instead of reusing the repo-root `MinecraftClient.ini`, to avoid leaking state between runs.
- For agent-driven local development, prefer `mcc-build`, `mcc-publish`, `mcc-build-clean`, `mcc-debug`, `mcc-run`, and `mcc-tui` over raw `dotnet build`, `dotnet publish`, or `dotnet run`, so worktree-local temp build routing stays active.
## Architecture
- `Program` bootstraps console I/O, TOML config, auth/session state, MC version selection, Forge detection, then creates `McClient`.
- `McClient` is the live session runtime: TCP client, selected protocol handler, Brigadier command dispatcher, loaded bots, world/inventory/entity state, queued chat, movement/pathing, reconnect flow.
- `Protocol/` is the network/auth boundary. `ProtocolHandler` maps Minecraft versions to protocol numbers and selects either `Protocol16Handler` (1.4.6-1.6.4) or `Protocol18Handler` (1.7.2+).
- `Scripting/ChatBot` is the extension boundary. Built-in bots and `/script` C# bots share the same event/tick API.
- Main runtime flow: console input -> internal Brigadier command or server chat; packets -> protocol handler -> `McClient` state update -> bot events; `OnUpdate()` (20 TPS) drives bot ticks, delayed work, chat cooldowns, movement, and main-thread tasks.
## Technology Stack
- Main app: C#, .NET 10, nullable enabled.
- Command system: `Brigadier.NET`.
- Config: TOML via `Samboy063.Tomlet`.
- Runtime scripting: Roslyn (`Microsoft.CodeAnalysis.CSharp`) with in-memory compilation.
- Networking/auth: custom Minecraft protocol handlers, DNS SRV lookup (`DnsClient`), Forge/session/profile-key support.
- Integrations: `DSharpPlus`, `Telegram.Bot`, `MessagePack`, `Magick.NET`, `Sentry`.
- Docs site: VuePress 2 (`docs/package.json`).
- Tooling: Docker, GitHub Actions, Python 3.10+ scripts under `tools/` for palette/version generation.
- Legacy UI: `MinecraftClientGUI` is a separate .NET Framework 4.0 WinForms wrapper, not the main runtime.
## Version Support
Feature columns mean:
- Inventory: `/inventory` plus inventory/container bot APIs
- Movement: terrain handling, `/move`, and movement/pathing bots
- Entity: entity tracking and entity-driven bot events
| Minecraft | Protocol path | Inventory | Movement | Entity | Notes |
| --- | --- | --- | --- | --- | --- |
| 1.4.6-1.6.4 | `Protocol16Handler` | No | No | No | Core login/chat only |
| 1.7.2-1.7.10 | `Protocol18Handler` | No | Yes | No | Pre-1.8 special case |
| 1.8-1.9.4 | `Protocol18Handler` | Partial / docs conflict | Yes | Yes | Runtime gates allow 1.8+, but docs still warn inventory is unsupported through 1.9 |
| 1.10-1.12.2 | `Protocol18Handler` | Yes | Yes | Yes | Pre-flattening palettes |
| 1.13-1.19.2 | `Protocol18Handler` | Yes | Yes | Yes | Flattened block/item/entity palettes |
| 1.19.3-1.20.4 | `Protocol18Handler` | Yes | Yes | Yes | Newer chat/signing and palette splits |
| 1.20.6-1.21.4 | `Protocol18Handler` | Yes | Yes | Yes | Registry-driven world/attribute handling |
| 1.21.5-1.21.8 | `Protocol18Handler` | Yes | Yes | Yes | 1.21.7/1.21.8 reuse 1.21.6 block/entity palettes in code |
| 1.21.9-1.21.10 | `Protocol18Handler` | Yes | Yes | Yes | Version tools prefer server data reports since 1.21.9 |
| 1.21.11 | `Protocol18Handler` | Yes | Yes | Yes | Own entity/item/metadata palettes; blocks reuse 1.21.9 palette |
| 26.1 | `Protocol18Handler` | Yes | Yes | Yes | Latest coded support; new Minecraft version naming scheme |
Notes:
- Declared code range is `1.4.6` to `26.1`.
- Human docs are stale in places and sometimes stop at older ranges; prefer code when docs and code disagree.
- Movement/pathing limits called out in docs still apply: no swimming, no knockback. The `Physics/` engine adds vanilla-accurate collision and movement but some edge cases remain.
## Module Map
### Core Runtime
| Module | What It Owns | Important Files |
| --- | --- | --- |
| `MinecraftClient/` | Main `net10.0` runtime assembly and the best starting point. `Program.cs` owns startup, config load/writeback, CLI handling, auth/version selection, update/data-generation entrypoints, and restart/failure flow. `McClient.cs` owns the live session runtime: protocol handler ownership, command dispatch, bot lifecycle, world/inventory/entity state, queued chat, movement ticks, reconnect/disconnect logic, and the main-thread invoke queue. `Settings.cs` defines the TOML schema and runtime/internal overrides used across the app. | `Program.cs`, `McClient.cs`, `Settings.cs`, `ConsoleIO.cs`, `Command.cs`, `UpgradeHelper.cs`, `AutoTimeout.cs` |
| `MinecraftClient/Protocol/` | Network/auth/session boundary. `ProtocolHandler.cs` does DNS SRV lookup, server ping/version detection, MC-version to protocol mapping, and handler selection. `Protocol16.cs` and `Protocol18.cs` implement the packet flow for legacy and modern versions. `Protocol18Terrain.cs` decodes chunk sections/biomes into `World`. `DataTypes.cs` is the low-level reader/writer layer for VarInts, metadata, NBT-like structures, and packet fields. `Message/`, `ProfileKey/`, `Session/`, `Handlers/Forge/`, `Handlers/PacketPalettes/`, `Handlers/Packet/`, and `Handlers/StructuredComponents/` cover chat/signing, cached auth, Forge, packet IDs, packet-level parsing, and 1.20.6+ item components with versioned registries under `StructuredComponents/Registries/`. | `Protocol/ProtocolHandler.cs`, `Protocol/Handlers/Protocol16.cs`, `Protocol/Handlers/Protocol18.cs`, `Protocol/Handlers/Protocol18Terrain.cs`, `Protocol/Handlers/DataTypes.cs`, `Protocol/Message/ChatParser.cs`, `Protocol/MicrosoftAuthentication.cs`, `Protocol/MojangAPI.cs` |
| `MinecraftClient/Mapping/` | World model, terrain storage, movement logic, and versioned block/entity metadata. `World.cs` stores chunk columns, dimension data, and 1.20.6+ registry-derived dimension/attribute mappings. `Chunk*`, `Block.cs`, and `Location.cs` are the terrain primitives. `Movement.cs` contains step generation, gravity/on-ground checks, and path execution support. `Material.cs` plus `BlockPalettes/*.cs` map block-state IDs to MCC materials. `Entity.cs`, `EntityType.cs`, `EntityPalettes/*.cs`, `EntityMetadataPalette.cs`, and `EntityMetadataPalettes/*.cs` do the same for entities and metadata serializers. | `Mapping/World.cs`, `Mapping/ChunkColumn.cs`, `Mapping/Chunk.cs`, `Mapping/Block.cs`, `Mapping/Location.cs`, `Mapping/Movement.cs`, `Mapping/RaycastHelper.cs`, `Mapping/Material.cs`, `Mapping/Entity.cs`, `Mapping/EntityType.cs` |
| `MinecraftClient/Inventory/` | Inventory/container snapshots, item decoding, and versioned item registries. `Container.cs` models player inventories and server windows, including slot contents and container properties. `Item.cs` bridges older NBT-based items with 1.20.6+ structured components. `ItemType.cs` plus `ItemPalettes/*.cs` provide version-specific item ID mapping. Enchantment, effects, and villager-trade files add higher-level semantics on top of raw inventory data. | `Inventory/Container.cs`, `Inventory/ContainerType.cs`, `Inventory/Item.cs`, `Inventory/ItemMovingHelper.cs`, `Inventory/ItemType.cs`, `Inventory/ItemPalettes/*.cs`, `Inventory/EnchantmentMapping.cs`, `Inventory/VillagerTrade.cs` |
| `MinecraftClient/Physics/` | Vanilla-accurate per-tick physics engine. `PlayerPhysics.cs` mirrors vanilla `Entity.move()`, `LivingEntity.aiStep()/travel()`, and `Player.travel()` logic at 20 TPS, handling ground/air/water/lava/creative-fly travel, jumping, sprint-jump boost, climbing, sneak-edge-detection, friction, drag, gravity, slow-falling, and levitation. `CollisionDetector.cs` resolves full AABB collisions against the block world including step-up, mirroring vanilla axis-separated resolution. `BlockShapes.cs` maps block-state IDs to collision AABBs using PrismarineJS data from `BlockShapeData.json`. `Vec3d.cs` and `Aabb.cs` provide the geometric primitives. `MovementInput.cs` captures player input state. | `Physics/PlayerPhysics.cs`, `Physics/PhysicsConsts.cs`, `Physics/CollisionDetector.cs`, `Physics/BlockShapes.cs`, `Physics/BlockShapeData.json`, `Physics/Vec3d.cs`, `Physics/Aabb.cs`, `Physics/MovementInput.cs` |
### Commands And Extensions
| Module | What It Owns | Important Files |
| --- | --- | --- |
| `MinecraftClient/Commands/` and `MinecraftClient/CommandHandler/` | Internal MCC command system built on Brigadier. Commands are discovered by reflection from `MinecraftClient.Commands` in `McClient.LoadCommands()`. Each file in `Commands/` registers one internal command. `ArgumentType/*.cs` provides typed Brigadier arguments and completion sources for accounts, bots, items, locations, scripts, inventories, and more. `Patch/*.cs` carries MCC-specific Brigadier extensions, and `CmdResult.cs` is the command execution result object. | `Command.cs`, `Commands/*.cs`, `CommandHandler/MccArguments.cs`, `CommandHandler/CmdResult.cs`, `CommandHandler/ArgumentType/*.cs`, `CommandHandler/Patch/*.cs` |
| `MinecraftClient/ChatBots/` | Built-in bots and bridges loaded from config through `McClient.RegisterBots()`. The folder mixes gameplay automation (`AutoAttack`, `AutoDig`, `AutoEat`, `AutoFishing`, `Farmer`), utility/logging bots (`ChatLog`, `PlayerListLogger`, `Alerts`), bridges (`DiscordBridge`, `TelegramBridge`, `RemoteControl`), and tooling like `ScriptScheduler`, `Map`, and `ReplayCapture`. | `ChatBots/AutoRelog.cs`, `ChatBots/Farmer.cs`, `ChatBots/FollowPlayer.cs`, `ChatBots/ItemsCollector.cs`, `ChatBots/Map.cs`, `ChatBots/RemoteControl.cs`, `ChatBots/ScriptScheduler.cs`, `ChatBots/DiscordBridge.cs`, `ChatBots/TelegramBridge.cs`, `ChatBots/ReplayCapture.cs` |
| `MinecraftClient/Scripting/` | Shared extension boundary for compiled bots and runtime C# scripts. `ChatBot.cs` is the main bot API and lifecycle surface. Built-in bots and `/script` bots use the same event model. `CSharpRunner.cs` parses `//MCCScript` files, compiles them with Roslyn, caches assemblies, and executes them through `CSharpAPI`. `DynamicRun/Builder/*` handles in-memory compilation/load-context plumbing, while `BotMovementLock.cs` coordinates movement ownership between automation pieces. | `Scripting/ChatBot.cs`, `Scripting/CSharpRunner.cs`, `Scripting/BotMovementLock.cs`, `Scripting/AssemblyResolver.cs`, `Scripting/DynamicRun/Builder/Compiler.cs`, `Scripting/DynamicRun/Builder/CompileRunner.cs` |
| `MinecraftClient/config/` | Sample runtime assets excluded from compilation. This is the examples/staging area for end-user scripts and standalone bots. `sample-script*.cs` shows supported `/script` patterns (basic, chatbot, world access, HTTP requests, tasks, PM forwarding, extended), while `config/ChatBots/*.cs` are copy/adapt examples rather than built-in bots. | `config/README.md`, `config/sample-script.cs`, `config/sample-script-with-chatbot.cs`, `config/sample-script-with-world-access.cs`, `config/sample-script-with-http-request.cs`, `config/sample-script-with-task.cs`, `config/ChatBots/*.cs` |
| `ConsoleInteractive/` | Required git submodule for richer line editing and console UI. MCC uses the submodule's `ConsoleReader`, `ConsoleWriter`, and suggestion UI from `ConsoleIO.cs` and `McClient.cs` when `BasicIO` is not enabled. | `ConsoleInteractive/README.md`, `ConsoleInteractive/ConsoleInteractive/ConsoleInteractive.sln` |
### Support And Tooling
| Module | What It Owns | Important Files |
| --- | --- | --- |
| `MinecraftClient/Logger/`, `MinecraftClient/Proxy/`, `MinecraftClient/Crypto/`, `MinecraftClient/Resources/`, `MinecraftClient/WinAPI/` | Support subsystems under the main app. Logging supports console/file output plus regex filtering. `ProxyHandler.cs` routes update/login/in-game traffic through HTTP or SOCKS proxies. `Crypto/` implements the stream ciphers needed for online-mode protocol encryption. `Resources/` contains UI strings, generated translation accessors, config help text, icons, and embedded Minecraft asset data. `WinAPI/` contains small Windows-only console helpers. | `Logger/FilteredLogger.cs`, `Logger/FileLogLogger.cs`, `Proxy/ProxyHandler.cs`, `Crypto/CryptoHandler.cs`, `Crypto/AesCfb8Stream.cs`, `Resources/Translations/Translations.resx`, `Resources/ConfigComments/ConfigComments.resx`, `Resources/en_us.json`, `WinAPI/ConsoleIcon.cs` |
| `docs/` | VuePress documentation site. `.vuepress/config.ts` sets bundler, theme, plugins, and redirects. `.vuepress/configs/**` holds locale and nav wiring. `guide/*.md` contains the user-facing install, usage, bot, and scripting docs. | `docs/.vuepress/config.ts`, `docs/.vuepress/configs/**`, `docs/guide/README.md`, `docs/guide/configuration.md`, `docs/guide/chat-bots.md`, `docs/guide/creating-bots.md`, `docs/guide/creating-text-script.md`, `docs/guide/ai-assisted-development.md` |
| `tools/` | Python helpers for Minecraft version adaptation and palette generation. `README.md` is the authoritative workflow. `diff_registries.py` compares versions and validates decompiled data against server reports. The `gen_*` scripts emit the versioned palette source files consumed by `Protocol/`, `Mapping/`, `Inventory/`, and `Physics/`. | `tools/README.md`, `tools/diff_registries.py`, `tools/gen_block_palette.py`, `tools/gen_item_palette.py`, `tools/gen_entity_palette.py`, `tools/gen_entity_metadata_palette.py`, `tools/gen_block_shapes.py`, `tools/gen_command_argument_registry.py` |
| `DebugTools/` | Standalone packet/proxy debugging utilities for inspecting traffic and compression behavior outside the main client runtime. | `DebugTools/MinecraftClientProxy/Program.cs`, `DebugTools/MinecraftClientProxy/PacketProxy.cs`, `DebugTools/MinecraftClientProxy/ZlibUtils.cs` |
| `MinecraftClientGUI/` | Legacy Windows GUI wrapper around the console app. WinForms shell that launches and communicates with the console executable; not part of the main `net10.0` runtime path. | `MinecraftClientGUI/Program.cs`, `MinecraftClientGUI/Form1.cs`, `MinecraftClientGUI/Form1.Designer.cs`, `MinecraftClientGUI/MinecraftClient.cs` |
## Engineering Guidance
Read `docs/guide/ai-assisted-development.md` before starting development work on MCC. It documents the full build-run-test loop, local server harness, repository tools, and standard workflows.
### DO
- Keep startup/config/auth logic in `Program` and connection runtime logic in `McClient` or `Protocol/*`.
- Update version support holistically: protocol constants, version mapping, packet palette, block palette, item palette, entity palette, metadata palette, and routing switches.
- Use `tools/` and authoritative server data reports when adapting to new Minecraft versions.
- Guard optional subsystems with `GetTerrainEnabled()`, `GetInventoryEnabled()`, and `GetEntityHandlingEnabled()` before using them.
- For built-in bots, wire all pieces together: bot class, `Settings.ChatBotConfigHealper`, and `McClient.RegisterBots()`.
- Keep `Initialize()` for setup/prereq checks and `AfterGameJoined()` for sending chat or commands.
- Normalize inbound chat with `GetVerbatim()` before `IsChatMessage()` / `IsPrivateMessage()`.
- Clean up commands, plugin channels, threads, timers, and movement locks in `OnUnload()`.
- Prefer nullable-aware code, pattern matching, `ArgumentNullException.ThrowIfNull`, `Try*` APIs for expected failures, and `InvokeOnMainThread()` for cross-thread state changes.
- Use modern C# 14 features.
- Use provided skills proactively depending on the context, read their descriptions to determine when to use them.
- All user-facing text (log messages, command output, TUI labels, notifications, help text, error messages) **must** go through the translation system: add entries to `Translations.resx` + `Translations.Designer.cs`, then reference `Translations.key_name` in code. Never hardcode user-visible strings directly in `.cs` files. Use `string.Format(Translations.key, ...)` for parameterized messages. Translation keys follow dot-delimited naming: `<module>.<scope>.<detail>` (e.g. `cmd.inventory.tui_opened`, `tui.inventory.controls`). Pure technical identifiers (class names, protocol constants, color codes) are exempt.
### DON'T
- Don't update only `MCVer2ProtocolVersion()` or only one palette file when adding a new Minecraft version.
- Don't send chat in `Initialize()`.
- Don't mutate inventory snapshots and expect server-side effects; use handler APIs/window actions.
- Don't bypass Brigadier with ad hoc command parsing.
- Never modify `ConsoleInteractive/`; treat it as an external required submodule.
- Don't start background workers when `Update()` or delayed tasks are sufficient; if you must, stop them on unload/disconnect.
- Don't leave movement locks, plugin channels, or dispatcher registrations behind.
- Don't trust older docs over current code for supported versions or feature gates. When AGENTS.md, skills, and older docs disagree, prefer current code and current tool behavior, then update the stale source.
- Don't hardcode user-facing strings (messages, labels, help text) directly in source code; always use `Translations.*` resources so the text can be localized.
- Never use "—" ("em dash"), unless specifically being instructed to do so!
- Never generate MCC config files (`.ini`) in the repo root. When `dotnet run --project MinecraftClient -- --help` is used to generate a config template, it writes `MinecraftClient.ini` to the current directory. Always run this command from a system temp directory (e.g. `mktemp -d`) or use `prepare_offline_mcc_config.sh` which already handles output routing.

1
CLAUDE.md Normal file
View file

@ -0,0 +1 @@
Read @AGENTS.md

@ -1 +1 @@
Subproject commit db2be2a7f8ea71c734ebeff6314fd2fdec73f4fc
Subproject commit ff6d2129e9f1e0fc6c7032741bdc42a2f0fa263e

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,773 @@
using System.Diagnostics;
using System.Text.Json;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
string endpoint = Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT") ?? "http://127.0.0.1:33333/mcp";
bool useStdio = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_USE_STDIO"), "1", StringComparison.Ordinal);
string? mcpAuthToken = Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN");
string repoRoot = FindRepoRoot();
string rconScript = Path.Combine(repoRoot, "tools", "mc-rcon.sh");
string rconPort = Environment.GetEnvironmentVariable("MCC_RCON_PORT") ?? "25575";
string rconPassword = Environment.GetEnvironmentVariable("MCC_RCON_PASSWORD") ?? "test123";
bool skipSetup = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_SKIP_SETUP"), "1", StringComparison.Ordinal);
bool runLocalSetup = !useStdio && !skipSetup && IsLocalEndpoint(endpoint) && File.Exists(rconScript);
var executed = new List<object>();
var checks = new List<string>();
try
{
await using McpClient client = useStdio
? await McpClient.CreateAsync(new StdioClientTransport(CreateStdioOptions()))
: await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(endpoint),
TransportMode = HttpTransportMode.AutoDetect,
AdditionalHeaders = string.IsNullOrWhiteSpace(mcpAuthToken)
? null
: new Dictionary<string, string> { ["Authorization"] = $"Bearer {mcpAuthToken}" }
}));
ToolEnvelope initialWorldState = await CallSuccessAsync(client, executed, "mcc_world_state");
JsonElement initialWorldData = RequireData(initialWorldState);
string botName = ReadString(initialWorldData, "username") ?? "MCCBot";
Coordinate initialLocation = ReadCoordinate(initialWorldData, "location");
long setupBaseline = 0;
if (runLocalSetup)
{
ToolEnvelope baselineEvents = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
{
["afterId"] = 0L,
["maxCount"] = 1
});
setupBaseline = ReadInt64(RequireData(baselineEvents), "latestId");
await PrepareWorldAsync(rconScript, rconPort, rconPassword, botName, initialLocation);
await Task.Delay(1500);
}
ToolEnvelope worldState = await WaitForPredicateAsync(
client,
executed,
"mcc_world_state",
null,
envelope =>
{
if (!envelope.Success || envelope.Data is not JsonElement data)
return false;
return data.TryGetProperty("loadedChunkCount", out JsonElement loaded)
&& loaded.TryGetInt32(out int loadedChunkCount)
&& loadedChunkCount >= 0
&& HasNonNullProperty(data, "worldAge")
&& HasNonNullProperty(data, "timeOfDay");
},
"mcc_world_state never reported chunk/time state.");
JsonElement worldData = RequireData(worldState);
Coordinate worldLocation = ReadCoordinate(worldData, "location");
string dimension = RequireString(worldData, "dimension");
int loadedChunkCount = ReadInt32(worldData, "loadedChunkCount");
int pendingChunkCount = ReadInt32(worldData, "pendingChunkCount");
int totalChunkCount = ReadInt32(worldData, "totalChunkCount");
double loadRatio = ReadDouble(worldData, "loadRatio");
_ = RequireString(worldData, "host");
_ = ReadInt32(worldData, "port");
_ = RequireString(worldData, "username");
_ = ReadInt32(worldData, "protocol");
_ = ReadDouble(worldData, "tps");
Ensure(!string.IsNullOrWhiteSpace(dimension), "mcc_world_state returned an empty dimension.");
Ensure(loadedChunkCount + pendingChunkCount == totalChunkCount, "mcc_world_state chunk counters are inconsistent.");
Ensure(loadRatio is >= 0 and <= 1, "mcc_world_state loadRatio is out of range.");
Ensure(HasNonNullProperty(worldData, "worldAge"), "mcc_world_state.worldAge is null.");
Ensure(HasNonNullProperty(worldData, "timeOfDay"), "mcc_world_state.timeOfDay is null.");
if (runLocalSetup || useStdio)
{
Ensure(HasNonNullProperty(worldData, "rainLevel"), "mcc_world_state.rainLevel is null after setup.");
Ensure(HasNonNullProperty(worldData, "thunderLevel"), "mcc_world_state.thunderLevel is null after setup.");
}
checks.Add("mcc_world_state");
ToolEnvelope chunkStatus = await CallSuccessAsync(client, executed, "mcc_chunk_status");
JsonElement chunkData = RequireData(chunkStatus);
JsonElement chunk = RequireProperty(chunkData, "chunk");
_ = ReadInt32(chunk, "x");
_ = ReadInt32(chunk, "z");
Ensure(ReadBoolean(chunkData, "loaded"), "mcc_chunk_status reported the current chunk as unloaded.");
Ensure(ReadInt32(chunkData, "loadedChunkCount") + ReadInt32(chunkData, "pendingChunkCount") == ReadInt32(chunkData, "totalChunkCount"),
"mcc_chunk_status chunk counters are inconsistent.");
checks.Add("mcc_chunk_status");
await CallSuccessAsync(client, executed, "mcc_look_direction", new Dictionary<string, object?> { ["direction"] = "Down" });
ToolEnvelope raycast = await CallSuccessAsync(client, executed, "mcc_raycast_block", new Dictionary<string, object?>
{
["maxDistance"] = 8.0,
["includeNeighbors"] = true
});
JsonElement raycastData = RequireData(raycast);
Ensure(ReadBoolean(raycastData, "hit"), "mcc_raycast_block did not report a hit after looking down.");
JsonElement raycastBlock = RequireProperty(raycastData, "block");
Ensure(!string.Equals(RequireString(raycastBlock, "material"), "Air", StringComparison.OrdinalIgnoreCase),
"mcc_raycast_block hit Air instead of a solid block.");
Ensure(RequireProperty(raycastData, "neighbors").ValueKind == JsonValueKind.Object,
"mcc_raycast_block did not include neighbors when requested.");
checks.Add("mcc_raycast_block");
ToolEnvelope pathPreview = await CallSuccessAsync(client, executed, "mcc_path_preview", new Dictionary<string, object?>
{
["x"] = Math.Floor(worldLocation.X) + 2,
["y"] = worldLocation.Y,
["z"] = Math.Floor(worldLocation.Z),
["allowUnsafe"] = false,
["timeoutMs"] = 2000,
["maxWaypoints"] = 32
});
JsonElement pathData = RequireData(pathPreview);
Ensure(ReadBoolean(pathData, "pathFound"), "mcc_path_preview did not find a path to a nearby target.");
Ensure(RequireProperty(pathData, "waypoints").GetArrayLength() > 0, "mcc_path_preview returned no waypoints.");
checks.Add("mcc_path_preview");
ToolEnvelope stoneSearch = await WaitForPredicateAsync(
client,
executed,
"mcc_inventory_search",
new Dictionary<string, object?>
{
["query"] = "Stone",
["maxCount"] = 20,
["exactMatch"] = true,
["includeContainers"] = false
},
envelope => envelope.Success && envelope.Data is JsonElement data && ReadInt32(data, "count") > 0,
"mcc_inventory_search never found Stone in the player inventory.");
Ensure(ContainsItemType(RequireData(stoneSearch), "Stone"), "mcc_inventory_search results did not include Stone.");
ToolEnvelope swordSearch = await WaitForPredicateAsync(
client,
executed,
"mcc_inventory_search",
new Dictionary<string, object?>
{
["query"] = "DiamondSword",
["maxCount"] = 20,
["exactMatch"] = true,
["includeContainers"] = false
},
envelope => envelope.Success && envelope.Data is JsonElement data && ReadInt32(data, "count") > 0,
"mcc_inventory_search never found DiamondSword in the player inventory.");
Ensure(ContainsItemType(RequireData(swordSearch), "DiamondSword"), "mcc_inventory_search results did not include DiamondSword.");
checks.Add("mcc_inventory_search");
ToolEnvelope selectItem = await CallSuccessAsync(client, executed, "mcc_select_item", new Dictionary<string, object?>
{
["itemType"] = "DiamondSword",
["preferLowestSlot"] = true
});
JsonElement selectData = RequireData(selectItem);
int selectedSlot = ReadInt32(selectData, "selectedSlot");
ToolEnvelope playerStats = await CallSuccessAsync(client, executed, "mcc_player_stats");
JsonElement playerStatsData = RequireData(playerStats);
Ensure(ReadInt32(playerStatsData, "currentSlot") == selectedSlot, "mcc_select_item did not update mcc_player_stats.currentSlot.");
_ = ReadInt32(playerStatsData, "playerEntityId");
_ = ReadInt32(playerStatsData, "level");
_ = ReadInt32(playerStatsData, "totalExperience");
_ = ReadCoordinate(playerStatsData, "location");
checks.Add("mcc_select_item");
checks.Add("mcc_player_stats");
ToolEnvelope playersDetailed = await CallSuccessAsync(client, executed, "mcc_players_detailed", new Dictionary<string, object?>
{
["includeSelf"] = true,
["includeCoordinates"] = true
});
JsonElement playersData = RequireData(playersDetailed);
JsonElement selfPlayer = FindPlayer(RequireProperty(playersData, "players"), botName);
_ = RequireString(selfPlayer, "uuid");
_ = ReadInt32(selfPlayer, "ping");
_ = ReadInt32(selfPlayer, "entityId");
_ = ReadDouble(selfPlayer, "x");
_ = ReadDouble(selfPlayer, "y");
_ = ReadDouble(selfPlayer, "z");
checks.Add("mcc_players_detailed");
ToolEnvelope statusEffects = await CallSuccessAsync(client, executed, "mcc_status_effects");
Ensure(RequireProperty(RequireData(statusEffects), "effects").ValueKind == JsonValueKind.Array,
"mcc_status_effects.effects is not an array.");
checks.Add("mcc_status_effects");
ToolEnvelope animation = await CallSuccessAsync(client, executed, "mcc_animation", new Dictionary<string, object?>
{
["hand"] = "MainHand"
});
Ensure(ReadBoolean(RequireData(animation), "success"), "mcc_animation did not report success.");
ToolEnvelope sneakOn = await CallSuccessAsync(client, executed, "mcc_toggle_sneak", new Dictionary<string, object?> { ["enabled"] = true });
Ensure(ReadBoolean(RequireData(sneakOn), "enabled"), "mcc_toggle_sneak(true) did not report enabled=true.");
ToolEnvelope sprintOn = await CallSuccessAsync(client, executed, "mcc_toggle_sprint", new Dictionary<string, object?> { ["enabled"] = true });
Ensure(ReadBoolean(RequireData(sprintOn), "enabled"), "mcc_toggle_sprint(true) did not report enabled=true.");
await CallSuccessAsync(client, executed, "mcc_look_angles", new Dictionary<string, object?>
{
["yaw"] = 45.0f,
["pitch"] = -15.0f
});
ToolEnvelope updatedStats = await CallSuccessAsync(client, executed, "mcc_player_stats");
JsonElement updatedStatsData = RequireData(updatedStats);
Ensure(Math.Abs(ReadDouble(updatedStatsData, "yaw") - 45.0) < 0.01, "mcc_look_angles did not update yaw.");
Ensure(Math.Abs(ReadDouble(updatedStatsData, "pitch") - (-15.0)) < 0.01, "mcc_look_angles did not update pitch.");
checks.Add("mcc_animation");
checks.Add("mcc_toggle_sneak");
checks.Add("mcc_toggle_sprint");
checks.Add("mcc_look_direction");
checks.Add("mcc_look_angles");
ToolEnvelope nearestEntity = await WaitForPredicateAsync(
client,
executed,
"mcc_entity_nearest",
new Dictionary<string, object?>
{
["typeFilter"] = "ArmorStand",
["radius"] = 16.0,
["includePlayers"] = false
},
envelope => envelope.Success,
"mcc_entity_nearest never found a nearby ArmorStand.");
JsonElement nearestData = RequireData(nearestEntity);
int entityId = ReadInt32(nearestData, "id");
Ensure(string.Equals(RequireString(nearestData, "type"), "ArmorStand", StringComparison.OrdinalIgnoreCase),
"mcc_entity_nearest did not return an ArmorStand.");
ToolEnvelope attackEntity = await CallSuccessAsync(client, executed, "mcc_entity_attack", new Dictionary<string, object?>
{
["entityId"] = entityId
});
Ensure(ReadBoolean(RequireData(attackEntity), "success"), "mcc_entity_attack did not report success.");
checks.Add("mcc_entity_nearest");
checks.Add("mcc_entity_attack");
long recentSetupAfterId = runLocalSetup ? setupBaseline : 0;
if (runLocalSetup || useStdio)
{
ToolEnvelope setupEvents = await WaitForRecentEventTypesAsync(
client,
executed,
recentSetupAfterId,
"weather_rain",
"title",
"actionbar");
JsonElement setupEventsData = RequireData(setupEvents);
Ensure(GetEventTypes(setupEventsData).Contains("weather_rain", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include weather_rain.");
Ensure(GetEventTypes(setupEventsData).Contains("title", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include title.");
Ensure(GetEventTypes(setupEventsData).Contains("actionbar", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include actionbar.");
}
ToolEnvelope actionbarEvents = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
{
["afterId"] = 0L,
["maxCount"] = 20,
["typeFilter"] = "actionbar"
});
JsonElement actionbarData = RequireData(actionbarEvents);
Ensure(ReadInt32(actionbarData, "count") > 0, "mcc_recent_events typeFilter=actionbar returned no events.");
Ensure(AllEventsMatchType(actionbarData, "actionbar"), "mcc_recent_events typeFilter returned mixed event types.");
long inventoryBaseline = ReadInt64(actionbarData, "latestId");
int chestX = (int)Math.Floor(worldLocation.X) + 2;
int chestY = (int)Math.Floor(worldLocation.Y);
int chestZ = (int)Math.Floor(worldLocation.Z);
ToolEnvelope openContainer = await WaitForPredicateAsync(
client,
executed,
"mcc_container_open_at",
new Dictionary<string, object?>
{
["x"] = chestX,
["y"] = chestY,
["z"] = chestZ,
["timeoutMs"] = 3000,
["closeCurrent"] = true
},
envelope => envelope.Success,
"mcc_open_container_at never opened the nearby chest.");
JsonElement inventoryInfo = RequireProperty(RequireData(openContainer), "inventory");
int openedInventoryId = ReadInt32(inventoryInfo, "id");
ToolEnvelope closeContainer = await CallSuccessAsync(client, executed, "mcc_container_close", new Dictionary<string, object?>
{
["inventoryId"] = openedInventoryId,
["timeoutMs"] = 3000
});
Ensure(ReadBoolean(RequireData(closeContainer), "closed"), "mcc_close_container did not close the chest.");
ToolEnvelope inventoryEvents = await WaitForRecentEventTypesAsync(
client,
executed,
inventoryBaseline,
"inventory_open",
"inventory_close");
JsonElement inventoryEventsData = RequireData(inventoryEvents);
Ensure(GetEventTypes(inventoryEventsData).Contains("inventory_open", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include inventory_open.");
Ensure(GetEventTypes(inventoryEventsData).Contains("inventory_close", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include inventory_close.");
checks.Add("mcc_recent_events");
if (runLocalSetup)
{
long deathBaseline = ReadInt64(inventoryEventsData, "latestId");
await RunRconCommandAsync(rconScript, rconPort, rconPassword, $"kill {botName}");
ToolEnvelope deathEvents = await WaitForRecentEventTypesAsync(client, executed, deathBaseline, "death");
Ensure(GetEventTypes(RequireData(deathEvents)).Contains("death", StringComparer.OrdinalIgnoreCase),
"mcc_recent_events never reported death after the RCON kill.");
long respawnBaseline = ReadInt64(RequireData(deathEvents), "latestId");
ToolEnvelope respawn = await CallSuccessAsync(client, executed, "mcc_respawn");
Ensure(ReadBoolean(RequireData(respawn), "success"), "mcc_respawn did not report success.");
ToolEnvelope respawnEvents = await WaitForRecentEventTypesAsync(client, executed, respawnBaseline, "respawn");
Ensure(GetEventTypes(RequireData(respawnEvents)).Contains("respawn", StringComparer.OrdinalIgnoreCase),
"mcc_recent_events never reported respawn after mcc_respawn.");
checks.Add("mcc_respawn");
}
else
{
long respawnBaseline = ReadInt64(RequireData(inventoryEvents), "latestId");
ToolEnvelope respawn = await CallSuccessAsync(client, executed, "mcc_respawn");
Ensure(ReadBoolean(RequireData(respawn), "success"), "mcc_respawn did not report success.");
ToolEnvelope respawnEvents = await WaitForRecentEventTypesAsync(client, executed, respawnBaseline, "respawn");
Ensure(GetEventTypes(RequireData(respawnEvents)).Contains("respawn", StringComparer.OrdinalIgnoreCase),
"mcc_recent_events never reported respawn after mcc_respawn.");
checks.Add("mcc_respawn");
}
ToolEnvelope loadedBots = await CallSuccessAsync(client, executed, "mcc_loaded_bots");
JsonElement bots = RequireProperty(RequireData(loadedBots), "bots");
Ensure(ContainsBot(bots, "McpServer"), "mcc_loaded_bots did not include McpServer.");
checks.Add("mcc_loaded_bots");
ToolEnvelope disconnect = await CallSuccessAsync(client, executed, "mcc_disconnect");
Ensure(ReadBoolean(RequireData(disconnect), "disconnecting"), "mcc_disconnect did not report disconnecting=true.");
checks.Add("mcc_disconnect");
if (!useStdio)
{
await AssertDisconnectStopsEndpointAsync(client, executed);
}
Console.WriteLine(JsonSerializer.Serialize(new
{
success = true,
endpoint,
useStdio,
runLocalSetup,
checks,
executed
}, new JsonSerializerOptions { WriteIndented = true }));
Environment.ExitCode = 0;
}
catch (Exception ex)
{
Console.WriteLine(JsonSerializer.Serialize(new
{
success = false,
endpoint,
useStdio,
runLocalSetup,
error = ex.Message,
checks,
executed
}, new JsonSerializerOptions { WriteIndented = true }));
Environment.ExitCode = 1;
}
static async Task PrepareWorldAsync(string rconScript, string rconPort, string rconPassword, string botName, Coordinate location)
{
string[] commands =
[
$"op {botName}",
$"gamemode creative {botName}",
$"tp {botName} 0 80 0",
$"item replace entity {botName} hotbar.0 with minecraft:stone 32",
$"item replace entity {botName} hotbar.1 with minecraft:diamond_sword 1",
$"execute as {botName} at @s run setblock ~2 ~ ~ minecraft:chest",
$"execute as {botName} at @s run summon minecraft:armor_stand ~2 ~ ~1",
"weather clear",
"weather rain",
$"title {botName} title {{\"text\":\"mcp_title\"}}",
$"title {botName} actionbar {{\"text\":\"mcp_actionbar\"}}"
];
foreach (string command in commands)
{
await RunRconCommandAsync(rconScript, rconPort, rconPassword, command);
}
}
static async Task RunRconCommandAsync(string rconScript, string rconPort, string rconPassword, string command)
{
ProcessStartInfo startInfo = new("bash")
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
startInfo.ArgumentList.Add(rconScript);
startInfo.ArgumentList.Add(command);
startInfo.ArgumentList.Add(rconPort);
startInfo.ArgumentList.Add(rconPassword);
using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start mc-rcon.sh.");
string stdout = await process.StandardOutput.ReadToEndAsync();
string stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"RCON command failed ({command}): {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}");
}
}
static async Task<ToolEnvelope> CallSuccessAsync(
McpClient client,
List<object> executed,
string toolName,
IReadOnlyDictionary<string, object?>? args = null)
{
ToolEnvelope envelope = await CallToolAsync(client, executed, toolName, args);
if (!envelope.Success)
{
throw new InvalidOperationException(
$"{toolName} failed with errorCode={envelope.ErrorCode ?? "<null>"} message={envelope.Message ?? "<null>"}.");
}
return envelope;
}
static async Task<ToolEnvelope> WaitForPredicateAsync(
McpClient client,
List<object> executed,
string toolName,
IReadOnlyDictionary<string, object?>? args,
Func<ToolEnvelope, bool> predicate,
string failureMessage,
int maxAttempts = 12,
int delayMs = 400)
{
ToolEnvelope? lastEnvelope = null;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
ToolEnvelope envelope = await CallToolAsync(client, executed, toolName, args);
lastEnvelope = envelope;
if (predicate(envelope))
return envelope;
await Task.Delay(delayMs);
}
throw new InvalidOperationException(
$"{failureMessage} Last result: success={lastEnvelope?.Success}, errorCode={lastEnvelope?.ErrorCode ?? "<null>"}.");
}
static async Task<ToolEnvelope> WaitForRecentEventTypesAsync(
McpClient client,
List<object> executed,
long afterId,
params string[] expectedTypes)
{
HashSet<string> expected = expectedTypes.ToHashSet(StringComparer.OrdinalIgnoreCase);
ToolEnvelope? lastEnvelope = null;
for (int attempt = 0; attempt < 12; attempt++)
{
ToolEnvelope envelope = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
{
["afterId"] = afterId,
["maxCount"] = 100
});
lastEnvelope = envelope;
JsonElement data = RequireData(envelope);
HashSet<string> actual = GetEventTypes(data);
if (expected.All(actual.Contains))
return envelope;
await Task.Delay(400);
}
throw new InvalidOperationException(
$"mcc_recent_events never reported: {string.Join(", ", expectedTypes)} after event id {afterId}. Last latestId={ReadInt64(RequireData(lastEnvelope!), "latestId")}.");
}
static async Task<ToolEnvelope> CallToolAsync(
McpClient client,
List<object> executed,
string toolName,
IReadOnlyDictionary<string, object?>? args = null)
{
CallToolResult result = await client.CallToolAsync(toolName, args);
string responseJson = ExtractResponseJson(result);
JsonElement root = JsonDocument.Parse(responseJson).RootElement.Clone();
JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement) ? dataElement.Clone() : null;
bool success = root.TryGetProperty("success", out JsonElement successElement)
&& successElement.ValueKind == JsonValueKind.True;
string? errorCode = ReadString(root, "errorCode");
string? message = ReadString(root, "message");
executed.Add(new
{
tool = toolName,
arguments = args,
isError = result.IsError,
success,
errorCode,
message,
response = root
});
return new ToolEnvelope(toolName, result.IsError ?? false, success, errorCode, message, root, data);
}
static async Task AssertDisconnectStopsEndpointAsync(McpClient client, List<object> executed)
{
for (int attempt = 0; attempt < 15; attempt++)
{
try
{
await CallToolAsync(client, executed, "mcc_world_state");
}
catch
{
return;
}
await Task.Delay(300);
}
throw new InvalidOperationException("The MCP endpoint still responded after mcc_disconnect.");
}
static string ExtractResponseJson(CallToolResult result)
{
if (result.Content is null)
throw new InvalidOperationException("Tool response did not contain any content blocks.");
foreach (ContentBlock content in result.Content)
{
if (content is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text))
return text.Text;
}
throw new InvalidOperationException("Tool response did not contain a text payload.");
}
static JsonElement RequireData(ToolEnvelope envelope)
{
if (envelope.Data is JsonElement data)
return data;
throw new InvalidOperationException($"{envelope.ToolName} returned no data payload.");
}
static JsonElement RequireProperty(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out JsonElement property))
return property;
throw new InvalidOperationException($"Missing required property '{propertyName}'.");
}
static string RequireString(JsonElement element, string propertyName)
{
string? value = ReadString(element, propertyName);
if (!string.IsNullOrWhiteSpace(value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is missing or empty.");
}
static string? ReadString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
static int ReadInt32(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
if (property.TryGetInt32(out int value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is not an Int32.");
}
static long ReadInt64(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
if (property.TryGetInt64(out long value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is not an Int64.");
}
static double ReadDouble(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
if (property.TryGetDouble(out double value))
return value;
throw new InvalidOperationException($"Property '{propertyName}' is not a Double.");
}
static bool ReadBoolean(JsonElement element, string propertyName)
{
JsonElement property = RequireProperty(element, propertyName);
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => throw new InvalidOperationException($"Property '{propertyName}' is not a Boolean.")
};
}
static bool HasNonNullProperty(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind != JsonValueKind.Null;
}
static Coordinate ReadCoordinate(JsonElement element, string propertyName)
{
JsonElement coordinate = RequireProperty(element, propertyName);
return new Coordinate(
ReadDouble(coordinate, "x"),
ReadDouble(coordinate, "y"),
ReadDouble(coordinate, "z"));
}
static JsonElement FindPlayer(JsonElement players, string playerName)
{
foreach (JsonElement player in players.EnumerateArray())
{
string? name = ReadString(player, "name");
if (string.Equals(name, playerName, StringComparison.OrdinalIgnoreCase))
return player;
}
throw new InvalidOperationException($"Could not find player '{playerName}' in mcc_players_detailed.");
}
static bool ContainsItemType(JsonElement searchData, string itemType)
{
JsonElement matches = RequireProperty(searchData, "matches");
foreach (JsonElement match in matches.EnumerateArray())
{
if (string.Equals(ReadString(match, "itemType"), itemType, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
static bool ContainsBot(JsonElement bots, string botName)
{
foreach (JsonElement bot in bots.EnumerateArray())
{
if (string.Equals(ReadString(bot, "name"), botName, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
static HashSet<string> GetEventTypes(JsonElement recentEventsData)
{
JsonElement events = RequireProperty(recentEventsData, "events");
return events.EnumerateArray()
.Select(entry => RequireString(entry, "type"))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
static bool AllEventsMatchType(JsonElement recentEventsData, string type)
{
JsonElement events = RequireProperty(recentEventsData, "events");
foreach (JsonElement entry in events.EnumerateArray())
{
if (!string.Equals(RequireString(entry, "type"), type, StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
static void Ensure(bool condition, string message)
{
if (!condition)
throw new InvalidOperationException(message);
}
static bool IsLocalEndpoint(string endpoint)
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri))
return false;
return string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Host, "127.0.0.1", StringComparison.OrdinalIgnoreCase)
|| string.Equals(uri.Host, "::1", StringComparison.OrdinalIgnoreCase);
}
static string FindRepoRoot()
{
string current = Directory.GetCurrentDirectory();
DirectoryInfo? directory = new(current);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "MinecraftClient.sln")))
return directory.FullName;
directory = directory.Parent;
}
return current;
}
static StdioClientTransportOptions CreateStdioOptions()
{
string? stdioBin = Environment.GetEnvironmentVariable("MCC_MCP_STDIO_BIN");
if (!string.IsNullOrWhiteSpace(stdioBin))
{
return new StdioClientTransportOptions
{
Name = "MCC MCP Stdio Harness",
Command = stdioBin,
Arguments = [],
ShutdownTimeout = TimeSpan.FromSeconds(5)
};
}
return new StdioClientTransportOptions
{
Name = "MCC MCP Stdio Harness",
Command = "dotnet",
Arguments =
[
"run",
"--project",
"DebugTools/MccMcpStdioHarness",
"-c",
"Release",
"--no-build"
],
ShutdownTimeout = TimeSpan.FromSeconds(5)
};
}
internal readonly record struct Coordinate(double X, double Y, double Z);
internal sealed record ToolEnvelope(
string ToolName,
bool IsError,
bool Success,
string? ErrorCode,
string? Message,
JsonElement Root,
JsonElement? Data);

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\MinecraftClient\MinecraftClient.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,984 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MinecraftClient.Mcp;
using ModelContextProtocol.Server;
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(options =>
{
options.LogToStandardErrorThreshold = LogLevel.Trace;
});
builder.Services.AddSingleton(new MccMcpConfig());
builder.Services.AddSingleton<IMccMcpCapabilities, DeterministicCapabilities>();
builder.Services.AddSingleton<MccMcpGuidanceProvider>();
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithTools<MccMcpToolSet>()
.WithPrompts<MccMcpPromptSet>();
await builder.Build().RunAsync();
internal sealed class DeterministicCapabilities : IMccMcpCapabilities
{
private static double C(double value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
private readonly List<RecentEvent> recentEvents = [];
private long nextEventId = 1;
private double playerX = C(0.5);
private double playerY = C(80.0);
private double playerZ = C(0.5);
private float yaw;
private float pitch;
private int currentSlot = 1;
private bool sneaking;
private bool sprinting;
private float health = 20.0f;
private bool disconnecting;
public DeterministicCapabilities()
{
AddRecentEvent("player_join", new { name = "HarnessBot" });
AddRecentEvent("inventory_open", new { inventoryId = 1, type = "Generic_9x3", title = "Chest" });
AddRecentEvent("weather_rain", new { level = 1.0 });
AddRecentEvent("title", new { text = "mcp_title" });
AddRecentEvent("actionbar", new { text = "mcp_actionbar" });
}
public MccMcpResult GetSessionStatus() =>
MccMcpResult.Ok(new
{
connected = !disconnecting,
host = "deterministic.local",
port = 25565,
username = "HarnessBot",
location = new { x = playerX, y = playerY, z = playerZ }
});
public MccMcpResult GetServerInfo() =>
MccMcpResult.Ok(new
{
host = "deterministic.local",
port = 25565,
tps = 20.0
});
public MccMcpResult GetPlayerState() =>
MccMcpResult.Ok(new
{
nickname = "HarnessBot",
username = "HarnessBot",
health,
saturation = 20,
gamemode = 1,
currentSlot,
yaw,
pitch,
location = new { x = playerX, y = playerY, z = playerZ },
effects = new object[0]
});
public MccMcpResult GetWorldState() =>
MccMcpResult.Ok(new
{
connected = !disconnecting,
host = "deterministic.local",
port = 25565,
username = "HarnessBot",
protocol = 769,
terrainEnabled = true,
inventoryEnabled = true,
entityHandlingEnabled = true,
location = new { x = playerX, y = playerY, z = playerZ },
tps = 20.0,
dimension = "minecraft:overworld",
loadedChunkCount = 9,
pendingChunkCount = 0,
totalChunkCount = 9,
loadRatio = 1.0,
worldAge = 12000L,
timeOfDay = 6000L,
rainLevel = 1.0,
thunderLevel = 0.0
});
public MccMcpResult GetChunkStatus(double? x, double? y, double? z)
{
double resolvedX = x ?? playerX;
double resolvedY = y ?? playerY;
double resolvedZ = z ?? playerZ;
int chunkX = (int)Math.Floor(resolvedX) >> 4;
int chunkZ = (int)Math.Floor(resolvedZ) >> 4;
return MccMcpResult.Ok(new
{
location = new { x = C(resolvedX), y = C(resolvedY), z = C(resolvedZ) },
chunk = new { x = chunkX, z = chunkZ },
loaded = true,
fullyLoaded = true,
loadedChunkCount = 9,
pendingChunkCount = 0,
totalChunkCount = 9,
loadRatio = 1.0
});
}
public MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors)
{
object? neighbors = includeNeighbors
? new
{
north = new { x = 0, y = 79, z = -1, material = "Air", typeLabel = "Air" },
south = new { x = 0, y = 79, z = 1, material = "Air", typeLabel = "Air" },
east = new { x = 1, y = 79, z = 0, material = "Air", typeLabel = "Air" },
west = new { x = -1, y = 79, z = 0, material = "Air", typeLabel = "Air" },
above = new { x = 0, y = 80, z = 0, material = "Air", typeLabel = "Air" },
below = new { x = 0, y = 78, z = 0, material = "Stone", typeLabel = "Stone" }
}
: null;
return MccMcpResult.Ok(new
{
hit = true,
maxDistance,
playerLocation = new { x = playerX, y = playerY, z = playerZ },
eyeLocation = new { x = playerX, y = C(playerY + 1.62), z = playerZ },
location = new { x = 0, y = 79, z = 0 },
block = new { material = "Stone", typeLabel = "Stone", blockId = 1, blockMeta = 0 },
distance = 1.12,
eyeDistance = 2.03,
neighbors
});
}
public MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints)
{
object[] waypoints =
[
new { x = playerX, y = playerY, z = playerZ },
new { x = C((playerX + x) / 2), y = C((playerY + y) / 2), z = C((playerZ + z) / 2) },
new { x = C(x), y = C(y), z = C(z) }
];
return MccMcpResult.Ok(new
{
pathFound = true,
exactReachable = true,
target = new { x = C(x), y = C(y), z = C(z) },
startLocation = new { x = playerX, y = playerY, z = playerZ },
finalWaypoint = new { x = C(x), y = C(y), z = C(z) },
finalDistance = 0.0,
waypointCount = waypoints.Length,
truncated = waypoints.Length > Math.Max(1, maxWaypoints),
waypoints = waypoints.Take(Math.Max(1, maxWaypoints)).ToArray(),
allowUnsafe,
maxOffset,
minOffset,
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
});
}
public MccMcpResult GetPlayersList() =>
MccMcpResult.Ok(new
{
players = new[] { "HarnessBot", "PlayerOne" }
});
public MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates)
{
List<object> players = [];
if (includeSelf)
{
players.Add(new
{
name = "HarnessBot",
uuid = Guid.Parse("11111111-1111-1111-1111-111111111111"),
ping = 5,
gamemode = 1,
listed = true,
displayName = "HarnessBot",
entityId = 1,
x = includeCoordinates ? playerX : (double?)null,
y = includeCoordinates ? playerY : (double?)null,
z = includeCoordinates ? playerZ : (double?)null
});
}
players.Add(new
{
name = "PlayerOne",
uuid = Guid.Parse("22222222-2222-2222-2222-222222222222"),
ping = 12,
gamemode = 1,
listed = true,
displayName = "PlayerOne",
entityId = 2,
x = includeCoordinates ? C(3.5) : (double?)null,
y = includeCoordinates ? C(80.0) : (double?)null,
z = includeCoordinates ? C(0.5) : (double?)null
});
return MccMcpResult.Ok(new
{
count = players.Count,
players = players.ToArray()
});
}
public MccMcpResult GetPlayerStats() =>
MccMcpResult.Ok(new
{
health,
saturation = 20,
level = 12,
totalExperience = 245,
gamemode = 1,
playerEntityId = 1,
currentSlot,
yaw,
pitch,
sneaking,
sprinting,
location = new { x = playerX, y = playerY, z = playerZ },
tps = 20.0
});
public MccMcpResult GetStatusEffects() =>
MccMcpResult.Ok(new
{
count = 0,
effects = Array.Empty<object>()
});
public MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter)
{
RecentEvent[] events = recentEvents
.Where(e => e.Id > afterId)
.Where(e => string.IsNullOrWhiteSpace(typeFilter) || string.Equals(e.Type, typeFilter, StringComparison.OrdinalIgnoreCase))
.Take(Math.Max(1, maxCount))
.ToArray();
return MccMcpResult.Ok(new
{
afterId,
latestId = recentEvents.Count > 0 ? recentEvents[^1].Id : 0,
count = events.Length,
events = events.Select(e => new
{
id = e.Id,
timestampUtc = e.TimestampUtc,
type = e.Type,
data = e.Data
}).ToArray()
});
}
public MccMcpResult GetLoadedBots() =>
MccMcpResult.Ok(new
{
count = 2,
bots = new object[]
{
new { name = "McpServer", fullTypeName = "MinecraftClient.ChatBots.McpServer", isScript = false },
new { name = "HarnessScript", fullTypeName = "MinecraftClient.ChatBots.Script", isScript = true }
}
});
public MccMcpResult GetChatHistory(int maxCount, bool includeJson) =>
MccMcpResult.Ok(new
{
count = 2,
entries = new object[]
{
new { timestampUtc = DateTimeOffset.UtcNow.AddSeconds(-10), kind = "chat", text = "<PlayerOne> hello", sender = "PlayerOne", message = "hello", json = includeJson ? "{}" : null },
new { timestampUtc = DateTimeOffset.UtcNow.AddSeconds(-5), kind = "system", text = "HarnessBot joined the game", sender = (string?)null, message = (string?)null, json = includeJson ? "{}" : null }
}
});
public MccMcpResult GetInternalCommands() =>
MccMcpResult.Ok(new
{
count = 4,
commands = new[]
{
new { name = "debug", usage = "debug [on|off|state]", description = "Toggle debug or print state." },
new { name = "move", usage = "move <x> <y> <z>", description = "Move to location." },
new { name = "useitem", usage = "useitem [x] [y] [z]", description = "Use current held item." },
new { name = "dig", usage = "dig <x> <y> <z> [duration]", description = "Dig block at location." }
}
});
public MccMcpResult GetMaterialsList(string? filter, int maxCount) =>
MccMcpResult.Ok(new
{
total = 3,
count = 3,
filter,
materials = new[]
{
new { name = "Air", typeLabel = "Air" },
new { name = "GrassBlock", typeLabel = "Grass Block" },
new { name = "OakLog", typeLabel = "Oak Log" }
}
});
public MccMcpResult GetBlockTypesList(string? filter, int maxCount) =>
MccMcpResult.Ok(new
{
total = 3,
count = 3,
filter,
blockTypes = new[]
{
new { name = "Air", typeLabel = "Air" },
new { name = "GrassBlock", typeLabel = "Grass Block" },
new { name = "OakLog", typeLabel = "Oak Log" }
}
});
public MccMcpResult GetEntityTypesList(string? filter, int maxCount) =>
MccMcpResult.Ok(new
{
total = 3,
count = 3,
filter,
entityTypes = new[]
{
new { name = "Player", typeLabel = "Player" },
new { name = "Item", typeLabel = "Item" },
new { name = "Villager", typeLabel = "Villager" }
}
});
public MccMcpResult SendChat(string text) =>
MccMcpResult.Ok(new { echoed = text });
public MccMcpResult QuitClient() =>
MccMcpResult.Ok(new { quitting = true });
public MccMcpResult DisconnectClient()
{
disconnecting = true;
AddRecentEvent("disconnect", new { reason = "requested", message = "Disconnect requested by test client." });
return MccMcpResult.Ok(new { disconnecting = true });
}
public MccMcpResult RunInternalCommand(string command) =>
MccMcpResult.Ok(new { command, status = "Done", output = "deterministic" });
public MccMcpResult UseItemOnHand() =>
MccMcpResult.Ok(new { success = true, action = "use_item_on_hand" });
public MccMcpResult ChangeHotbarSlot(int slot)
{
currentSlot = slot;
return MccMcpResult.Ok(new { success = true, slot });
}
public MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot)
{
currentSlot = string.Equals(itemType, "DiamondSword", StringComparison.OrdinalIgnoreCase) ? 2 : 1;
return MccMcpResult.Ok(new
{
success = true,
itemType,
inventorySlot = currentSlot - 1,
selectedSlot = currentSlot,
count = string.Equals(itemType, "DiamondSword", StringComparison.OrdinalIgnoreCase) ? 1 : 32,
preferLowestSlot
});
}
public MccMcpResult UseItemOnBlock(double x, double y, double z) =>
MccMcpResult.Ok(new { success = true, x = C(x), y = C(y), z = C(z), action = "useitem" });
public MccMcpResult DigBlock(double x, double y, double z, double durationSeconds) =>
MccMcpResult.Ok(new
{
success = true,
target = new { x = C(x), y = C(y), z = C(z) },
beforeBlock = new { material = "OakLog", typeLabel = "Oak Log", blockId = 137, blockMeta = 0 },
afterBlock = new { material = "Air", typeLabel = "Air", blockId = 0, blockMeta = 0 },
commandAccepted = true,
changed = true,
destroyed = true,
attempts = 1,
attemptedDurationsSeconds = new[] { durationSeconds > 0 ? durationSeconds : 1.5 },
distance = 1.5,
playerLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }
});
public MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock) =>
MccMcpResult.Ok(new { success = true, x, y, z, face, hand, lookAtBlock, action = "place_block" });
public MccMcpResult InteractEntity(int entityId, string interaction, string hand) =>
MccMcpResult.Ok(new { success = true, entityId, interaction, hand });
public MccMcpResult AttackEntity(int entityId) =>
MccMcpResult.Ok(new { success = true, entityId, interaction = "Attack" });
public MccMcpResult FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers)
{
bool wantsArmorStand = string.IsNullOrWhiteSpace(typeFilter)
|| string.Equals(typeFilter, "ArmorStand", StringComparison.OrdinalIgnoreCase)
|| string.Equals(typeFilter, "Armor Stand", StringComparison.OrdinalIgnoreCase);
if (wantsArmorStand && radius >= 4.0)
{
return MccMcpResult.Ok(new
{
id = 7,
type = "ArmorStand",
typeLabel = "Armor Stand",
uuid = Guid.Parse("33333333-3333-3333-3333-333333333333"),
name = "Armor Stand",
customName = (string?)null,
x = C(2.5),
y = C(80.0),
z = C(0.5),
distance = 2.0,
health = 20.0f,
pose = "Standing",
latency = 0
});
}
if (includePlayers && radius >= 3.0)
{
return MccMcpResult.Ok(new
{
id = 2,
type = "Player",
typeLabel = "Player",
uuid = Guid.Parse("22222222-2222-2222-2222-222222222222"),
name = string.IsNullOrWhiteSpace(nameFilter) ? "PlayerOne" : nameFilter,
customName = (string?)null,
x = C(3.5),
y = C(80.0),
z = C(0.5),
distance = 3.0,
health = 20.0f,
pose = "Standing",
latency = 12
});
}
return MccMcpResult.Fail("invalid_state", data: new { typeFilter, nameFilter, radius, includePlayers });
}
public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter) =>
MccMcpResult.Ok(new
{
center = new { x = 0, y = 79, z = 0 },
radius,
count = 1,
blocks = new[]
{
new { x = 0, y = 79, z = 0, material = materialFilter ?? "GrassBlock", blockId = 9, blockMeta = 0, distance = 0.0 }
}
});
public MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch) =>
MccMcpResult.Ok(new
{
center = new { x = 0, y = 79, z = 0 },
radius,
query,
exactMatch,
count = 2,
blocks = new object[]
{
new { x = 1, y = 79, z = 0, material = "GrassBlock", typeLabel = "Grass Block", blockId = 9, blockMeta = 0, distance = 1.0 },
new { x = 2, y = 79, z = 0, material = "Dirt", typeLabel = "Dirt", blockId = 10, blockMeta = 0, distance = 2.0 }
}
});
public MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf) =>
MccMcpResult.Ok(new
{
radius,
playerName,
includeSelf,
anyNearby = true,
count = 1,
players = new object[]
{
new
{
entityId = 1,
uuid = Guid.Empty,
name = "PlayerOne",
customName = (string?)null,
x = C(3.5),
y = C(80.0),
z = C(0.5),
distance = 3.0,
latency = 5
}
}
});
public MccMcpResult LocatePlayer(string playerName, bool includeSelf) =>
MccMcpResult.Ok(new
{
playerName,
matchedName = "PlayerOne",
entityId = 1,
uuid = Guid.Empty,
x = C(3.5),
y = C(80.0),
z = C(0.5),
distance = 3.0
});
public MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs) =>
MccMcpResult.Ok(new
{
reachable = true,
exactReachable = true,
target = new { x = C(x), y = C(y), z = C(z) },
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
finalWaypoint = new { x = C(x), y = C(y), z = C(z) },
finalDistance = 0.0,
waypointCount = 4,
allowUnsafe,
maxOffset,
minOffset,
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
});
public MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) =>
MccMcpResult.Ok(new
{
pathFound = true,
arrived = true,
tolerance = 1.5,
verifyWaitMs = 250,
target = new { x = C(x), y = C(y), z = C(z) },
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
finalLocation = new { x = C(x), y = C(y), z = C(z) },
finalDistance = 0.0,
distanceMoved = 3.0,
allowUnsafe,
allowDirectTeleport,
maxOffset,
minOffset,
timeoutMs
});
public MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) =>
MccMcpResult.Ok(new
{
pathFound = true,
arrived = true,
tolerance = 1.5,
verifyWaitMs = 250,
target = new
{
playerName = "PlayerOne",
entityId = 1,
x = C(3.5),
y = C(80.0),
z = C(0.5)
},
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
finalLocation = new { x = C(3.5), y = C(80.0), z = C(0.5) },
finalDistance = 0.0,
distanceMoved = 3.0,
allowUnsafe,
allowDirectTeleport,
maxOffset,
minOffset,
timeoutMs
});
public MccMcpResult LookAt(double x, double y, double z) =>
MccMcpResult.Ok(new { looked = true, x = C(x), y = C(y), z = C(z) });
public MccMcpResult LookDirection(string direction)
{
switch (direction.Trim().ToLowerInvariant())
{
case "up":
yaw = 0.0f;
pitch = -90.0f;
break;
case "down":
yaw = 0.0f;
pitch = 90.0f;
break;
case "north":
yaw = 180.0f;
pitch = 0.0f;
break;
case "south":
yaw = 0.0f;
pitch = 0.0f;
break;
case "east":
yaw = -90.0f;
pitch = 0.0f;
break;
case "west":
yaw = 90.0f;
pitch = 0.0f;
break;
}
return MccMcpResult.Ok(new { success = true, direction, yaw, pitch });
}
public MccMcpResult LookAngles(float yaw, float pitch)
{
this.yaw = yaw;
this.pitch = pitch;
return MccMcpResult.Ok(new { success = true, yaw, pitch });
}
public MccMcpResult PlayAnimation(string hand) =>
MccMcpResult.Ok(new { success = true, hand });
public MccMcpResult ToggleSneak(bool enabled)
{
sneaking = enabled;
return MccMcpResult.Ok(new { success = true, enabled = sneaking });
}
public MccMcpResult ToggleSprint(bool enabled)
{
sprinting = enabled;
return MccMcpResult.Ok(new { success = true, enabled = sprinting });
}
public MccMcpResult ListInventories() =>
MccMcpResult.Ok(new
{
count = 2,
inventories = new object[]
{
new { id = 0, type = "PlayerInventory", title = "Player Inventory", slotCount = 46, nonEmptySlots = 1, active = false },
new { id = 1, type = "Generic_9x3", title = "Chest", slotCount = 63, nonEmptySlots = 2, active = true }
}
});
public MccMcpResult GetInventorySnapshot(int inventoryId) =>
MccMcpResult.Ok(new
{
id = inventoryId,
type = inventoryId == 0 ? "PlayerInventory" : "Generic_9x3",
title = inventoryId == 0 ? "Player Inventory" : "Chest",
slotCount = inventoryId == 0 ? 46 : 63,
slots = new[]
{
new { slot = 0, type = "Stone", count = 64 }
}
});
public MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers)
{
List<object> matches = [];
if (query.Contains("stone", StringComparison.OrdinalIgnoreCase))
{
matches.Add(new
{
inventoryId = 0,
inventoryType = "PlayerInventory",
inventoryTitle = "Player Inventory",
slot = 0,
itemType = "Stone",
typeLabel = "Stone",
count = 32,
isPlayerInventory = true,
hotbarSlot = 1
});
}
if (query.Contains("diamond", StringComparison.OrdinalIgnoreCase) || query.Contains("sword", StringComparison.OrdinalIgnoreCase))
{
matches.Add(new
{
inventoryId = 0,
inventoryType = "PlayerInventory",
inventoryTitle = "Player Inventory",
slot = 1,
itemType = "DiamondSword",
typeLabel = "Diamond Sword",
count = 1,
isPlayerInventory = true,
hotbarSlot = 2
});
}
if (includeContainers)
{
matches.Add(new
{
inventoryId = 1,
inventoryType = "Generic_9x3",
inventoryTitle = "Chest",
slot = 0,
itemType = "Stone",
typeLabel = "Stone",
count = 16,
isPlayerInventory = false,
hotbarSlot = (int?)null
});
}
object[] result = matches.Take(Math.Max(1, maxCount)).ToArray();
return MccMcpResult.Ok(new
{
query,
exactMatch,
includeContainers,
count = result.Length,
matches = result
});
}
public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent)
{
AddRecentEvent("inventory_open", new { inventoryId = 1, type = "Generic_9x3", title = "Chest", x, y, z });
return MccMcpResult.Ok(new
{
success = true,
openAccepted = true,
opened = true,
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs,
x,
y,
z,
block = new { material = "Chest", typeLabel = "Chest", blockId = 0, blockMeta = 0 },
inventory = new { id = 1, type = "Generic_9x3", title = "Chest", slotCount = 63, nonEmptySlots = 2 }
});
}
public MccMcpResult CloseContainer(int inventoryId, int timeoutMs)
{
int resolvedInventoryId = inventoryId <= 0 ? 1 : inventoryId;
AddRecentEvent("inventory_close", new { inventoryId = resolvedInventoryId });
return MccMcpResult.Ok(new
{
success = true,
closed = true,
inventoryId = resolvedInventoryId,
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
});
}
public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType) =>
MccMcpResult.Ok(new { success = true, inventoryId, slotId, actionType });
public MccMcpResult DropInventoryItem(string itemType, int count, int inventoryId, bool preferStack) =>
MccMcpResult.Ok(new
{
success = true,
itemType,
requestedCount = count,
droppedCount = count,
beforeCount = 64,
afterCount = Math.Max(0, 64 - count),
inventoryId,
touchedSlots = new[] { 36 },
preferStack
});
public MccMcpResult DepositContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack) =>
MccMcpResult.Ok(new
{
success = true,
direction = "deposit",
itemType,
requestedCount = count,
movedCount = count,
beforePlayerCount = 64,
afterPlayerCount = Math.Max(0, 64 - count),
beforeContainerCount = 0,
afterContainerCount = count,
inventoryId = inventoryId <= 0 ? 1 : inventoryId,
containerType = "Generic_9x3",
touchedSourceSlots = new[] { 36 },
touchedTargetSlots = new[] { 0 }
});
public MccMcpResult WithdrawContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack) =>
MccMcpResult.Ok(new
{
success = true,
direction = "withdraw",
itemType,
requestedCount = count,
movedCount = count,
beforePlayerCount = 0,
afterPlayerCount = count,
beforeContainerCount = 64,
afterContainerCount = Math.Max(0, 64 - count),
inventoryId = inventoryId <= 0 ? 1 : inventoryId,
containerType = "Generic_9x3",
touchedSourceSlots = new[] { 0 },
touchedTargetSlots = new[] { 36 }
});
public MccMcpResult QueryEntities(int maxCount) =>
MccMcpResult.Ok(new
{
count = 1,
entities = new[]
{
new { id = 1, type = "Player", x = C(0.5), y = C(80.0), z = C(0.5) }
}
});
public MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius) =>
MccMcpResult.Ok(new
{
totalTracked = 1,
count = 1,
entities = new[]
{
new
{
id = 1,
type = "Player",
typeLabel = "Player",
uuid = Guid.Empty,
name = "HarnessBot",
customName = (string?)null,
x = C(0.5),
y = C(80.0),
z = C(0.5),
distance = 0.0,
health = 20.0f,
pose = "Standing",
latency = 5
}
}
});
public MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects) =>
MccMcpResult.Ok(new
{
id = entityId,
type = "Player",
typeLabel = "Player",
uuid = Guid.Empty,
name = "HarnessBot",
customName = (string?)null,
customNameVisible = false,
x = C(0.5),
y = C(80.0),
z = C(0.5),
yaw = 0.0f,
pitch = 0.0f,
health = 20.0f,
pose = "Standing",
latency = 5,
objectData = -1,
metadata = includeMetadata ? new { flags = 0 } : null,
equipment = includeEquipment ? new[] { new { slot = 0, type = "Stone", count = 1 } } : null,
activeEffects = includeEffects ? new object[0] : null
});
public MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText) =>
MccMcpResult.Ok(new
{
text,
exactMatch,
radius,
includeBackText,
count = 1,
signs = new[]
{
new
{
x = 2,
y = 80,
z = 1,
material = "OakSign",
typeLabel = "Oak Sign",
distance = 1.8,
isWaxed = false,
frontText = new[] { "home", "storage" },
backText = includeBackText ? new[] { "north wall" } : Array.Empty<string>(),
matchedLines = new[] { text }
}
}
});
public MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount) =>
MccMcpResult.Ok(new
{
itemType = itemType ?? "OakLog",
radius,
count = 1,
items = new[]
{
new
{
entityId = 99,
itemType = "OakLog",
typeLabel = "Oak Log",
count = 3,
x = C(2.5),
y = C(80.0),
z = C(1.5),
distance = 2.24
}
}
});
public MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs) =>
MccMcpResult.Ok(new
{
itemType,
radius,
maxItems,
allowUnsafe,
timeoutMs = timeoutMs <= 0 ? 2500 : timeoutMs,
attempted = 1,
successfulPickups = 1,
collectedCount = 3,
initialInventoryCount = 0,
finalInventoryCount = 3,
remainingNearby = 0,
attempts = new object[]
{
new
{
entityId = 99,
itemType,
typeLabel = "Oak Log",
expectedCount = 3,
target = new { x = C(2.5), y = C(80.0), z = C(1.5) },
pathFound = true,
arrived = true,
entityGone = true,
inventoryDelta = 3,
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
finalLocation = new { x = C(2.5), y = C(80.0), z = C(1.5) },
finalDistance = 0.0
}
}
});
public MccMcpResult Respawn()
{
health = 20.0f;
AddRecentEvent("respawn", new { location = new { x = playerX, y = playerY, z = playerZ } });
return MccMcpResult.Ok(new { success = true, respawned = true });
}
public MccMcpResult GetWorldBlockAt(int x, int y, int z) =>
MccMcpResult.Ok(new { x, y, z, material = "Air", blockId = 0, blockMeta = 0 });
private void AddRecentEvent(string type, object? data)
{
recentEvents.Add(new RecentEvent(nextEventId++, DateTimeOffset.UtcNow, type, data));
if (recentEvents.Count > 100)
recentEvents.RemoveAt(0);
}
private sealed record RecentEvent(long Id, DateTimeOffset TimestampUtc, string Type, object? Data);
}

View file

@ -0,0 +1,36 @@
using DebugTools.MccMcpWebPlayground.Contracts;
using DebugTools.MccMcpWebPlayground.Harness;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace DebugTools.MccMcpWebPlayground.Api;
public static class MccPlaygroundEndpoints
{
public static IEndpointRouteBuilder MapMccPlaygroundEndpoints(this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder api = endpoints.MapGroup("/api");
api.MapGet("/health", () => Results.Ok(new { ok = true }));
api.MapGet("/config", (IOptions<MccWebHarnessOptions> options) =>
{
MccWebHarnessOptions harnessOptions = options.Value;
return Results.Ok(new MccConfigResponse(
Model: harnessOptions.ResolveModel(),
OpenRouterBaseUrl: harnessOptions.ResolveOpenRouterBaseUrl(),
McpEndpoint: harnessOptions.ResolveMcpEndpoint(),
HasApiKey: harnessOptions.HasApiKeyConfigured(),
ExposeInventoryWindowAction: harnessOptions.ExposeInventoryWindowAction,
ExposeInternalCommandTool: harnessOptions.ExposeInternalCommandTool));
});
api.MapPost("/chat/stream", (ChatStreamRequest request, IMccAgentRunService runService, HttpContext httpContext, CancellationToken cancellationToken) =>
{
return TypedResults.ServerSentEvents(runService.StreamAsync(request, httpContext, cancellationToken));
})
.WithRequestTimeout("mcc-stream");
return endpoints;
}
}

View file

@ -0,0 +1,94 @@
using System.Text.Json.Serialization;
namespace DebugTools.MccMcpWebPlayground.Contracts;
public sealed class ChatStreamRequest
{
public List<ChatMessage>? Messages { get; set; }
}
public sealed class ChatMessage
{
public string Role { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
}
public sealed record MccConfigResponse(
string? Model,
string OpenRouterBaseUrl,
string McpEndpoint,
bool HasApiKey,
bool ExposeInventoryWindowAction,
bool ExposeInternalCommandTool);
public sealed record MccStreamEnvelope(string RunId, long Sequence, string Kind, object Data);
public sealed record MccRunStartedData(string Model, string McpEndpoint, DateTimeOffset StartedAtUtc);
public sealed record MccGuidanceLoadedData(
string SourceTool,
string CanonicalPromptName,
string GuidanceVersion,
MccCapabilityStatus CapabilityStatus);
public sealed record MccStateSummaryData(
int TurnCount,
int ToolCallCount,
bool SoftFinish,
int DirectAnswerAttempts,
IReadOnlyList<MccVerificationObligationView> OpenVerification,
IReadOnlyList<MccEvidenceView> RecentEvidence,
string? CompactionSummary);
public sealed record MccToolCalledData(string CallId, string Name, string ArgumentsJson, bool Advanced, bool Sensitive);
public sealed record MccToolResultData(
string CallId,
string Name,
bool IsError,
bool Success,
string? ErrorCode,
string Summary,
string RawText,
string EvidenceId);
public sealed record MccVerificationEventData(string ObligationId, string ToolName, string Kind, string Description);
public sealed record MccBudgetData(
int TurnCount,
int MaxTurns,
int ToolCallCount,
int MaxToolCalls,
double ElapsedSeconds,
int MaxWallClockSeconds);
public sealed record MccErrorData(string Code, string Message, string? Detail = null);
public sealed record MccFinalPayload(
string Status,
string Headline,
string AnswerMarkdown,
IReadOnlyList<string> VerifiedFacts,
IReadOnlyList<string> OpenIssues,
IReadOnlyList<string> EvidenceIds,
string? NextAction);
public sealed record MccSubmitFinalArgs(
string Status,
string Headline,
string AnswerMarkdown,
IReadOnlyList<string> VerifiedFacts,
IReadOnlyList<string> OpenIssues,
IReadOnlyList<string> EvidenceIds,
string? NextAction);
public sealed record MccCapabilityStatus(
[property: JsonPropertyName("sessionStatus")] bool SessionStatus,
[property: JsonPropertyName("chatAndCommands")] bool ChatAndCommands,
[property: JsonPropertyName("movement")] bool Movement,
[property: JsonPropertyName("inventory")] bool Inventory,
[property: JsonPropertyName("entityWorld")] bool EntityWorld);
public sealed record MccEvidenceView(string Id, string ToolName, string Summary, bool IsError);
public sealed record MccVerificationObligationView(string Id, string ToolName, string Kind, string Description);

View file

@ -0,0 +1,852 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Contracts;
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
using DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace DebugTools.MccMcpWebPlayground.Harness;
public interface IMccAgentRunService
{
IAsyncEnumerable<SseItem<MccStreamEnvelope>> StreamAsync(ChatStreamRequest request, HttpContext httpContext, CancellationToken cancellationToken);
}
public sealed class MccAgentRunService : IMccAgentRunService
{
private readonly MccMcpSessionFactory sessionFactory;
private readonly MccGuidanceSource guidanceSource;
private readonly MccPromptComposer promptComposer;
private readonly MccContextCompressor contextCompressor;
private readonly MccFinalizer finalizer;
private readonly OpenRouterChatClient openRouterChatClient;
private readonly MccWebHarnessOptions options;
public MccAgentRunService(
MccMcpSessionFactory sessionFactory,
MccGuidanceSource guidanceSource,
MccPromptComposer promptComposer,
MccContextCompressor contextCompressor,
MccFinalizer finalizer,
OpenRouterChatClient openRouterChatClient,
IOptions<MccWebHarnessOptions> options)
{
this.sessionFactory = sessionFactory;
this.guidanceSource = guidanceSource;
this.promptComposer = promptComposer;
this.contextCompressor = contextCompressor;
this.finalizer = finalizer;
this.openRouterChatClient = openRouterChatClient;
this.options = options.Value;
}
public async IAsyncEnumerable<SseItem<MccStreamEnvelope>> StreamAsync(
ChatStreamRequest request,
HttpContext httpContext,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, httpContext.RequestAborted);
CancellationToken linkedToken = linkedCts.Token;
string runId = Guid.NewGuid().ToString("n");
long sequence = 0;
string? model = options.ResolveModel();
if (string.IsNullOrWhiteSpace(model))
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_MODEL or MccWebHarness:Model must be configured."));
yield break;
}
if (!options.HasApiKeyConfigured())
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_API_KEY is not set."));
yield break;
}
List<object> baseConversationMessages = NormalizeConversation(request.Messages);
string userRequest = ExtractUserRequest(request.Messages);
if (string.IsNullOrWhiteSpace(userRequest))
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("invalid_request", "No user message was provided."));
yield break;
}
await using McpClient client = await sessionFactory.CreateAsync(linkedToken);
MccGuidanceBundle guidance = await guidanceSource.LoadAsync(client, linkedToken);
MccRunState runState = new()
{
RunId = runId,
UserRequest = userRequest,
BaseConversationMessages = baseConversationMessages,
ConfiguredModel = model,
Guidance = guidance
};
yield return CreateEvent(runId, ref sequence, "run_started", new MccRunStartedData(model, options.ResolveMcpEndpoint(), runState.StartedAtUtc));
yield return CreateEvent(runId, ref sequence, "guidance_loaded", new MccGuidanceLoadedData(
guidance.SourceToolName,
guidance.CanonicalPromptName,
guidance.GuidanceVersion,
guidance.CapabilityStatus));
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: linkedToken);
MccToolCatalog catalog = MccToolPolicy.BuildCatalog(tools, options, finalizer.BuildSubmitToolSchema());
while (!linkedToken.IsCancellationRequested)
{
runState.TurnCount++;
contextCompressor.CompactIfNeeded(runState);
yield return CreateEvent(runId, ref sequence, "state_summary", BuildStateSummary(runState, options));
if (runState.IsSoftFinish(options, DateTimeOffset.UtcNow))
{
yield return CreateEvent(runId, ref sequence, "budget", BuildBudgetData(runState));
}
if (runState.IsHardStop(options, DateTimeOffset.UtcNow))
break;
MccModelTurn? turn = null;
Exception? providerException = null;
try
{
turn = await openRouterChatClient.CreateTurnAsync(
promptComposer.Compose(runState),
catalog.ModelVisibleTools,
options,
linkedToken);
}
catch (Exception ex)
{
providerException = ex;
}
if (providerException is not null || turn is null)
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("provider_error", "OpenRouter request failed.", providerException?.Message));
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
yield break;
}
runState.RoutedModel = turn.ModelId;
runState.RoutedProvider = turn.RoutedProvider;
if (turn.ToolCalls.Count == 0)
{
runState.DirectAnswerAttempts++;
string content = string.IsNullOrWhiteSpace(turn.AssistantContent) ? "(empty assistant turn)" : turn.AssistantContent.Trim();
runState.ToolConversationMessages.Add(new Dictionary<string, object?>
{
["role"] = "assistant",
["content"] = content
});
if (runState.DirectAnswerAttempts >= 4)
{
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData(
"model_protocol_error",
"The model kept returning plain assistant text instead of using tools or mcc_submit_final.",
content));
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
yield break;
}
runState.ToolConversationMessages.Add(new Dictionary<string, object?>
{
["role"] = "user",
["content"] = "The previous plain assistant text was not accepted by this harness. On your next turn, you must either call the relevant MCC tools or call mcc_submit_final. Do not answer with plain assistant text again."
});
continue;
}
Dictionary<string, object?> assistantMessage = new()
{
["role"] = "assistant",
["content"] = turn.AssistantContent,
["tool_calls"] = turn.ToolCalls.Select(call => new Dictionary<string, object?>
{
["id"] = call.CallId,
["type"] = "function",
["function"] = new Dictionary<string, object?>
{
["name"] = call.Name,
["arguments"] = call.ArgumentsJson
}
}).ToArray()
};
runState.ToolConversationMessages.Add(assistantMessage);
foreach (MccModelToolCall toolCall in turn.ToolCalls)
{
MccToolProfile profile = MccToolPolicy.GetProfile(toolCall.Name);
yield return CreateEvent(runId, ref sequence, "tool_called", new MccToolCalledData(
toolCall.CallId,
toolCall.Name,
toolCall.ArgumentsJson,
profile.Risk == MccToolRisk.EscapeHatch,
profile.Risk == MccToolRisk.Sensitive));
if (toolCall.Name.Equals("mcc_submit_final", StringComparison.OrdinalIgnoreCase))
{
MccFinalizationValidation validation = finalizer.Validate(runState, toolCall.ArgumentsJson);
if (validation.Accepted)
{
yield return CreateEvent(runId, ref sequence, "final", validation.Payload!);
yield break;
}
string localResultText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "invalid_final_submission",
message = validation.ErrorText
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "invalid_final_submission",
Summary: validation.ErrorText ?? "Invalid final submission.",
RawText: localResultText,
EvidenceId: string.Empty));
continue;
}
if (MccToolPolicy.RequiresExplicitUserIntent(toolCall.Name) && !MccToolPolicy.HasExplicitUserIntent(runState.UserRequest, toolCall.Name))
{
string localResultText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "explicit_user_intent_required",
message = $"Tool '{toolCall.Name}' requires explicit user intent."
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "explicit_user_intent_required",
Summary: $"Tool '{toolCall.Name}' requires explicit user intent.",
RawText: localResultText,
EvidenceId: string.Empty));
continue;
}
if (!catalog.ToolsByName.TryGetValue(toolCall.Name, out MccToolCatalogEntry? entry))
{
string unknownToolText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "unknown_tool",
message = $"Unknown tool '{toolCall.Name}'."
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, unknownToolText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "unknown_tool",
Summary: $"Unknown tool '{toolCall.Name}'.",
RawText: unknownToolText,
EvidenceId: string.Empty));
continue;
}
CallToolResult? result = null;
Exception? toolException = null;
try
{
Dictionary<string, object?> arguments = MccJsonArguments.Parse(toolCall.ArgumentsJson);
result = await client.CallToolAsync(toolCall.Name, arguments, cancellationToken: linkedToken);
}
catch (Exception ex)
{
toolException = ex;
}
if (toolException is not null || result is null)
{
string failedText = JsonSerializer.Serialize(new
{
success = false,
errorCode = "tool_call_failed",
message = toolException?.Message
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, failedText));
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
IsError: true,
Success: false,
ErrorCode: "tool_call_failed",
Summary: toolException?.Message ?? "Tool call failed.",
RawText: failedText,
EvidenceId: string.Empty));
continue;
}
runState.ToolCallCount++;
MccNormalizedToolResult normalized = MccMcpJson.Normalize(result);
MccEvidenceRecord evidence = CreateEvidence(runState, toolCall.Name, normalized);
runState.Evidence.Add(evidence);
runState.ToolExecutions.Add(new MccToolExecutionRecord
{
CallId = toolCall.CallId,
ToolName = toolCall.Name,
ArgumentsJson = toolCall.ArgumentsJson,
Evidence = evidence
});
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, normalized.Text));
foreach (MccVerificationObligation obligation in CreateObligations(runState, evidence, toolCall.ArgumentsJson))
{
runState.VerificationObligations.Add(obligation);
yield return CreateEvent(runId, ref sequence, "verification_required", new MccVerificationEventData(
obligation.Id,
obligation.ToolName,
obligation.Kind,
obligation.Description));
if (obligation.Cleared)
{
yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData(
obligation.Id,
obligation.ToolName,
obligation.Kind,
obligation.Description));
}
}
foreach (MccVerificationObligation cleared in TryClearObligationsFromEvidence(runState, evidence))
{
yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData(
cleared.Id,
cleared.ToolName,
cleared.Kind,
cleared.Description));
}
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
toolCall.CallId,
toolCall.Name,
evidence.IsError,
evidence.Success,
evidence.ErrorCode,
evidence.Summary,
evidence.RawText,
evidence.Id));
}
}
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
}
private static List<object> NormalizeConversation(List<ChatMessage>? incoming)
{
List<object> messages = [];
if (incoming is null)
return messages;
foreach (ChatMessage message in incoming)
{
if (string.IsNullOrWhiteSpace(message.Role) || string.IsNullOrWhiteSpace(message.Content))
continue;
string role = message.Role.Trim().ToLowerInvariant();
if (role is not ("user" or "assistant" or "system"))
continue;
messages.Add(new Dictionary<string, object?>
{
["role"] = role,
["content"] = message.Content.Trim()
});
}
return messages;
}
private static string ExtractUserRequest(List<ChatMessage>? incoming)
{
return incoming?
.LastOrDefault(message => string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(message.Content))
?.Content
?.Trim()
?? string.Empty;
}
private static Dictionary<string, object?> BuildToolMessage(string callId, string content)
{
return new Dictionary<string, object?>
{
["role"] = "tool",
["tool_call_id"] = callId,
["content"] = content
};
}
private static MccStateSummaryData BuildStateSummary(MccRunState runState, MccWebHarnessOptions options)
{
return new MccStateSummaryData(
TurnCount: runState.TurnCount,
ToolCallCount: runState.ToolCallCount,
SoftFinish: runState.IsSoftFinish(options, DateTimeOffset.UtcNow),
DirectAnswerAttempts: runState.DirectAnswerAttempts,
OpenVerification: runState.OpenObligations
.Select(obligation => new MccVerificationObligationView(obligation.Id, obligation.ToolName, obligation.Kind, obligation.Description))
.ToArray(),
RecentEvidence: runState.Evidence
.TakeLast(6)
.Select(evidence => new MccEvidenceView(evidence.Id, evidence.ToolName, evidence.Summary, evidence.IsError))
.ToArray(),
CompactionSummary: runState.CompactionSummary);
}
private MccBudgetData BuildBudgetData(MccRunState runState)
{
return new MccBudgetData(
TurnCount: runState.TurnCount,
MaxTurns: options.MaxTurns,
ToolCallCount: runState.ToolCallCount,
MaxToolCalls: options.MaxToolCalls,
ElapsedSeconds: (DateTimeOffset.UtcNow - runState.StartedAtUtc).TotalSeconds,
MaxWallClockSeconds: options.MaxWallClockSeconds);
}
private static MccEvidenceRecord CreateEvidence(MccRunState runState, string toolName, MccNormalizedToolResult result)
{
string summary = SummarizeEvidence(toolName, result);
return new MccEvidenceRecord
{
Id = runState.NextEvidenceId(),
ToolName = toolName,
Summary = summary,
RawText = result.Text,
IsError = result.IsError,
Success = result.Success,
ErrorCode = result.ErrorCode,
Root = result.Root,
Data = result.Data
};
}
private static string SummarizeEvidence(string toolName, MccNormalizedToolResult result)
{
if (result.Data is JsonElement data)
{
if ((toolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase) || toolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase))
&& TryReadBool(data, "arrived", out bool arrived))
{
return arrived
? $"movement verified; arrived={arrived}"
: $"movement not yet verified; arrived={arrived}";
}
if (toolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase))
{
bool destroyed = TryReadBool(data, "destroyed", out bool destroyedValue) && destroyedValue;
bool changed = TryReadBool(data, "changed", out bool changedValue) && changedValue;
return $"dig result changed={changed} destroyed={destroyed}";
}
if (toolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase))
{
int successful = TryReadInt(data, "successfulPickups", out int successfulValue) ? successfulValue : 0;
int collected = TryReadInt(data, "collectedCount", out int collectedValue) ? collectedValue : 0;
return $"pickup result successfulPickups={successful} collectedCount={collected}";
}
if (toolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase)
&& TryReadBool(data, "opened", out bool opened))
{
return $"container open result opened={opened}";
}
if (toolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item")
{
int moved = TryReadInt(data, "movedCount", out int movedValue)
? movedValue
: TryReadInt(data, "droppedCount", out int droppedValue) ? droppedValue : 0;
return $"{toolName} movedCount={moved}";
}
}
string prefix = result.IsError ? "error" : "ok";
return $"{prefix}: {Truncate(result.Text.Replace('\n', ' '), 180)}";
}
private List<MccVerificationObligation> CreateObligations(MccRunState runState, MccEvidenceRecord evidence, string argumentsJson)
{
List<MccVerificationObligation> obligations = [];
JsonElement metadata = ParseArgumentsToJson(argumentsJson);
if (evidence.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase))
{
MccVerificationObligation obligation = new()
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "movement",
Description = "Verify final player location for the requested move target.",
SourceEvidenceId = evidence.Id,
Metadata = BuildMoveMetadata(evidence, metadata),
Cleared = IsMovementVerified(evidence)
};
obligations.Add(obligation);
return obligations;
}
if (evidence.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase))
{
MccVerificationObligation obligation = new()
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "movement",
Description = "Verify final proximity to the requested player target.",
SourceEvidenceId = evidence.Id,
Metadata = BuildMoveToPlayerMetadata(evidence, metadata),
Cleared = IsMovementVerified(evidence)
};
obligations.Add(obligation);
return obligations;
}
if (evidence.ToolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase))
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "container",
Description = "Verify that the target container is open and active.",
SourceEvidenceId = evidence.Id,
Metadata = null,
Cleared = IsContainerOpenVerified(evidence)
});
return obligations;
}
if (evidence.ToolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item")
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "inventory",
Description = "Verify the requested inventory delta.",
SourceEvidenceId = evidence.Id,
Metadata = evidence.Data,
Cleared = IsInventoryVerified(evidence)
});
return obligations;
}
if (evidence.ToolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase))
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "pickup",
Description = "Verify that the requested dropped items were picked up.",
SourceEvidenceId = evidence.Id,
Metadata = evidence.Data,
Cleared = IsPickupVerified(evidence)
});
return obligations;
}
if (evidence.ToolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase))
{
obligations.Add(new MccVerificationObligation
{
Id = runState.NextObligationId(),
ToolName = evidence.ToolName,
Kind = "block_change",
Description = "Verify that the target block changed state after digging.",
SourceEvidenceId = evidence.Id,
Metadata = evidence.Data,
Cleared = IsDigVerified(evidence)
});
}
return obligations;
}
private List<MccVerificationObligation> TryClearObligationsFromEvidence(MccRunState runState, MccEvidenceRecord evidence)
{
List<MccVerificationObligation> cleared = [];
foreach (MccVerificationObligation obligation in runState.OpenObligations)
{
if (obligation.Cleared)
continue;
if (obligation.Kind == "movement" && TryClearMovementObligation(obligation, evidence))
{
obligation.Cleared = true;
obligation.ClearedByEvidenceId = evidence.Id;
cleared.Add(obligation);
continue;
}
if (obligation.Kind == "block_change" && TryClearDigObligation(obligation, evidence))
{
obligation.Cleared = true;
obligation.ClearedByEvidenceId = evidence.Id;
cleared.Add(obligation);
}
}
return cleared;
}
private static bool TryClearMovementObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence)
{
if (evidence.ToolName.Equals("mcc_player_state", StringComparison.OrdinalIgnoreCase)
&& evidence.Data is JsonElement data
&& data.TryGetProperty("location", out JsonElement location)
&& obligation.Metadata is JsonElement metadata)
{
if (obligation.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase)
&& metadata.TryGetProperty("x", out JsonElement targetX)
&& metadata.TryGetProperty("y", out JsonElement targetY)
&& metadata.TryGetProperty("z", out JsonElement targetZ))
{
double tolerance = metadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 1.5;
return TryReadDouble(location, "x", out double x)
&& TryReadDouble(location, "y", out double y)
&& TryReadDouble(location, "z", out double z)
&& Distance(x, y, z, targetX.GetDouble(), targetY.GetDouble(), targetZ.GetDouble()) <= tolerance;
}
}
if (evidence.ToolName.Equals("mcc_player_locate", StringComparison.OrdinalIgnoreCase)
&& obligation.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase)
&& evidence.Data is JsonElement playerData
&& obligation.Metadata is JsonElement playerMetadata)
{
string? expectedName = playerMetadata.TryGetProperty("playerName", out JsonElement nameElement) ? nameElement.GetString() : null;
string? matchedName = playerData.TryGetProperty("matchedName", out JsonElement matchedNameElement) ? matchedNameElement.GetString() : null;
if (!string.IsNullOrWhiteSpace(expectedName) && !string.Equals(expectedName, matchedName, StringComparison.OrdinalIgnoreCase))
return false;
if (TryReadDouble(playerData, "distance", out double distance))
{
double tolerance = playerMetadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 2.0;
return distance <= tolerance;
}
}
return false;
}
private static bool TryClearDigObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence)
{
if (!evidence.ToolName.Equals("mcc_world_block_at", StringComparison.OrdinalIgnoreCase)
|| evidence.Data is not JsonElement data
|| obligation.Metadata is not JsonElement metadata)
{
return false;
}
if (!metadata.TryGetProperty("target", out JsonElement target)
|| !TryReadDouble(target, "x", out double x)
|| !TryReadDouble(target, "y", out double y)
|| !TryReadDouble(target, "z", out double z))
{
return false;
}
return TryReadInt(data, "x", out int blockX)
&& TryReadInt(data, "y", out int blockY)
&& TryReadInt(data, "z", out int blockZ)
&& Math.Abs(blockX - x) < 0.5
&& Math.Abs(blockY - y) < 0.5
&& Math.Abs(blockZ - z) < 0.5
&& data.TryGetProperty("block", out JsonElement block)
&& block.TryGetProperty("material", out JsonElement material)
&& !string.Equals(material.GetString(), "Air", StringComparison.OrdinalIgnoreCase);
}
private static bool IsMovementVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
if (TryReadBool(data, "arrived", out bool arrived) && arrived)
return true;
if (TryReadDouble(data, "finalDistance", out double finalDistance))
{
double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5;
return finalDistance <= tolerance;
}
return false;
}
private static bool IsContainerOpenVerified(MccEvidenceRecord evidence)
{
return evidence.Data is JsonElement data
&& TryReadBool(data, "opened", out bool opened)
&& opened;
}
private static bool IsInventoryVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
if (TryReadInt(data, "requestedCount", out int requestedCount)
&& TryReadInt(data, "movedCount", out int movedCount))
{
return movedCount == requestedCount;
}
if (TryReadInt(data, "requestedCount", out requestedCount)
&& TryReadInt(data, "droppedCount", out int droppedCount))
{
return droppedCount == requestedCount;
}
return evidence.Success;
}
private static bool IsPickupVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
return (TryReadInt(data, "successfulPickups", out int successfulPickups) && successfulPickups > 0)
|| (TryReadInt(data, "collectedCount", out int collectedCount) && collectedCount > 0);
}
private static bool IsDigVerified(MccEvidenceRecord evidence)
{
if (evidence.Data is not JsonElement data)
return false;
return (TryReadBool(data, "destroyed", out bool destroyed) && destroyed)
|| (TryReadBool(data, "changed", out bool changed) && changed);
}
private static JsonElement? BuildMoveMetadata(MccEvidenceRecord evidence, JsonElement arguments)
{
if (evidence.Data is not JsonElement data)
return null;
double x = TryReadDoubleFromArguments(arguments, "x", out double targetX)
? targetX
: data.TryGetProperty("target", out JsonElement target) && TryReadDouble(target, "x", out double fromDataX) ? fromDataX : 0;
double y = TryReadDoubleFromArguments(arguments, "y", out double targetY)
? targetY
: data.TryGetProperty("target", out target) && TryReadDouble(target, "y", out double fromDataY) ? fromDataY : 0;
double z = TryReadDoubleFromArguments(arguments, "z", out double targetZ)
? targetZ
: data.TryGetProperty("target", out target) && TryReadDouble(target, "z", out double fromDataZ) ? fromDataZ : 0;
double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5;
return JsonSerializer.SerializeToElement(new
{
x,
y,
z,
tolerance
});
}
private static JsonElement? BuildMoveToPlayerMetadata(MccEvidenceRecord evidence, JsonElement arguments)
{
string? playerName = arguments.TryGetProperty("playerName", out JsonElement property) ? property.GetString() : null;
double tolerance = evidence.Data is JsonElement data && TryReadDouble(data, "tolerance", out double tol) ? tol : 2.0;
return JsonSerializer.SerializeToElement(new
{
playerName,
tolerance
});
}
private static JsonElement ParseArgumentsToJson(string argumentsJson)
{
try
{
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
return document.RootElement.Clone();
}
catch
{
using JsonDocument document = JsonDocument.Parse("{}");
return document.RootElement.Clone();
}
}
private static bool TryReadBool(JsonElement element, string propertyName, out bool value)
{
value = false;
return element.TryGetProperty(propertyName, out JsonElement property)
&& property.ValueKind is JsonValueKind.True or JsonValueKind.False
&& ((value = property.GetBoolean()) || !value || true);
}
private static bool TryReadInt(JsonElement element, string propertyName, out int value)
{
value = 0;
return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetInt32(out value);
}
private static bool TryReadDouble(JsonElement element, string propertyName, out double value)
{
value = 0;
return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetDouble(out value);
}
private static bool TryReadDoubleFromArguments(JsonElement element, string propertyName, out double value)
{
value = 0;
if (!element.TryGetProperty(propertyName, out JsonElement property))
return false;
return property.ValueKind == JsonValueKind.Number
? property.TryGetDouble(out value)
: property.ValueKind == JsonValueKind.String && double.TryParse(property.GetString(), out value);
}
private static double Distance(double x1, double y1, double z1, double x2, double y2, double z2)
{
double dx = x1 - x2;
double dy = y1 - y2;
double dz = z1 - z2;
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}
private static string Truncate(string text, int maxLength)
{
return string.IsNullOrEmpty(text) || text.Length <= maxLength ? text : text[..maxLength] + "...";
}
private static SseItem<MccStreamEnvelope> CreateEvent<T>(string runId, ref long sequence, string kind, T data)
{
sequence++;
return new SseItem<MccStreamEnvelope>(
new MccStreamEnvelope(runId, sequence, kind, data!),
kind)
{
EventId = sequence.ToString(CultureInfo.InvariantCulture)
};
}
}

View file

@ -0,0 +1,21 @@
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccContextCompressor
{
public void CompactIfNeeded(MccRunState runState)
{
if (runState.Evidence.Count <= 6)
return;
IReadOnlyList<MccEvidenceRecord> olderEvidence = runState.Evidence
.Take(Math.Max(0, runState.Evidence.Count - 6))
.ToArray();
if (olderEvidence.Count == 0)
return;
runState.CompactionSummary = string.Join('\n', olderEvidence
.TakeLast(8)
.Select(record => $"- {record.Id} {record.ToolName}: {record.Summary}"));
}
}

View file

@ -0,0 +1,221 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using DebugTools.MccMcpWebPlayground.Contracts;
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccFinalizer
{
private static readonly string[] AllowedStatuses = ["completed", "partial", "blocked", "clarification_needed", "failed"];
public object BuildSubmitToolSchema()
{
return new Dictionary<string, object?>
{
["type"] = "function",
["function"] = new Dictionary<string, object?>
{
["name"] = "mcc_submit_final",
["description"] = "Submit the final result for this MCC run. Use completed only when no required verification obligations remain open.",
["parameters"] = new JsonObject
{
["type"] = "object",
["additionalProperties"] = false,
["properties"] = new JsonObject
{
["status"] = new JsonObject
{
["type"] = "string",
["enum"] = new JsonArray(AllowedStatuses.Select(status => JsonValue.Create(status)).ToArray())
},
["headline"] = new JsonObject { ["type"] = "string" },
["answerMarkdown"] = new JsonObject { ["type"] = "string" },
["verifiedFacts"] = new JsonObject
{
["type"] = "array",
["items"] = new JsonObject { ["type"] = "string" }
},
["openIssues"] = new JsonObject
{
["type"] = "array",
["items"] = new JsonObject { ["type"] = "string" }
},
["evidenceIds"] = new JsonObject
{
["type"] = "array",
["items"] = new JsonObject { ["type"] = "string" }
},
["nextAction"] = new JsonObject
{
["type"] = new JsonArray("string", "null")
}
},
["required"] = new JsonArray("status", "headline", "answerMarkdown", "verifiedFacts", "openIssues", "evidenceIds", "nextAction")
}
}
};
}
public MccFinalizationValidation Validate(MccRunState runState, string argumentsJson)
{
try
{
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
JsonElement root = document.RootElement;
MccSubmitFinalArgs submission = new(
Status: ReadRequiredString(root, "status"),
Headline: ReadRequiredString(root, "headline"),
AnswerMarkdown: ReadRequiredString(root, "answerMarkdown"),
VerifiedFacts: ReadStringArray(root, "verifiedFacts"),
OpenIssues: ReadStringArray(root, "openIssues"),
EvidenceIds: ReadStringArray(root, "evidenceIds"),
NextAction: ReadNullableString(root, "nextAction"));
string normalizedStatus = submission.Status.Trim().ToLowerInvariant();
if (!AllowedStatuses.Contains(normalizedStatus, StringComparer.Ordinal))
return MccFinalizationValidation.Reject("Invalid final status.");
if (string.IsNullOrWhiteSpace(submission.Headline) || string.IsNullOrWhiteSpace(submission.AnswerMarkdown))
return MccFinalizationValidation.Reject("headline and answerMarkdown are required.");
Dictionary<string, MccEvidenceRecord> evidenceById = runState.Evidence.ToDictionary(record => record.Id, StringComparer.OrdinalIgnoreCase);
Dictionary<string, string> evidenceAliasByCallId = new(StringComparer.OrdinalIgnoreCase);
foreach (MccToolExecutionRecord execution in runState.ToolExecutions)
{
evidenceAliasByCallId[execution.CallId] = execution.Evidence.Id;
int suffixSeparator = execution.CallId.LastIndexOf('_');
if (suffixSeparator >= 0 && suffixSeparator < execution.CallId.Length - 1)
evidenceAliasByCallId[execution.CallId[(suffixSeparator + 1)..]] = execution.Evidence.Id;
}
List<string> normalizedEvidenceIds = [];
foreach (string evidenceId in submission.EvidenceIds)
{
string normalizedEvidenceId = evidenceAliasByCallId.TryGetValue(evidenceId, out string? mappedEvidenceId)
? mappedEvidenceId
: evidenceId;
if (!evidenceById.ContainsKey(normalizedEvidenceId))
return MccFinalizationValidation.Reject($"Unknown evidence id '{evidenceId}'.");
if (!normalizedEvidenceIds.Contains(normalizedEvidenceId, StringComparer.OrdinalIgnoreCase))
normalizedEvidenceIds.Add(normalizedEvidenceId);
}
if (normalizedStatus == "completed" && runState.OpenObligations.Count > 0)
return MccFinalizationValidation.Reject("completed is invalid while verification obligations remain open.");
if (!AreVerifiedFactsGrounded(submission.VerifiedFacts, normalizedEvidenceIds, evidenceById))
return MccFinalizationValidation.Reject("verifiedFacts must be grounded in the referenced evidence.");
return MccFinalizationValidation.Accept(new MccFinalPayload(
normalizedStatus,
submission.Headline.Trim(),
submission.AnswerMarkdown.Trim(),
submission.VerifiedFacts,
submission.OpenIssues,
normalizedEvidenceIds,
string.IsNullOrWhiteSpace(submission.NextAction) ? null : submission.NextAction.Trim()));
}
catch (Exception ex)
{
return MccFinalizationValidation.Reject($"Invalid mcc_submit_final payload: {ex.Message}");
}
}
public MccFinalPayload BuildHardStopResult(MccRunState runState, MccWebHarnessOptions options)
{
IReadOnlyList<string> openIssues = runState.OpenObligations.Count > 0
? runState.OpenObligations.Select(obligation => obligation.Description).ToArray()
: ["The harness reached its execution budget before the run was explicitly finalized."];
IReadOnlyList<string> evidenceIds = runState.Evidence.TakeLast(4).Select(record => record.Id).ToArray();
IReadOnlyList<string> verifiedFacts = runState.Evidence
.TakeLast(4)
.Where(record => record.Success)
.Select(record => record.Summary)
.ToArray();
return new MccFinalPayload(
Status: runState.OpenObligations.Count > 0 ? "partial" : "blocked",
Headline: "Run stopped before explicit completion",
AnswerMarkdown: "I could not finish the request within the current harness budget. I am returning the strongest verified state captured so far.",
VerifiedFacts: verifiedFacts,
OpenIssues: openIssues,
EvidenceIds: evidenceIds,
NextAction: "Retry with a fresh run if you want me to continue from the latest verified state.");
}
private static bool AreVerifiedFactsGrounded(
IReadOnlyList<string> verifiedFacts,
IReadOnlyList<string> evidenceIds,
IReadOnlyDictionary<string, MccEvidenceRecord> evidenceById)
{
if (verifiedFacts.Count == 0)
return true;
if (evidenceIds.Count == 0)
return false;
string evidenceCorpus = string.Join(' ', evidenceIds
.Where(evidenceById.ContainsKey)
.Select(id => evidenceById[id].Summary))
.ToLowerInvariant();
foreach (string fact in verifiedFacts)
{
HashSet<string> factTokens = fact.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(token => token.Trim().Trim(',', '.', ':', ';', '!', '?', '"', '\''))
.Where(token => token.Length >= 4)
.Select(token => token.ToLowerInvariant())
.ToHashSet(StringComparer.Ordinal);
if (factTokens.Count == 0)
continue;
int matches = factTokens.Count(token => evidenceCorpus.Contains(token, StringComparison.Ordinal));
if (matches < Math.Min(2, factTokens.Count))
return false;
}
return true;
}
private static string ReadRequiredString(JsonElement root, string propertyName)
{
string? value = ReadNullableString(root, propertyName);
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException($"{propertyName} is required.");
return value.Trim();
}
private static string? ReadNullableString(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out JsonElement property))
return null;
return property.ValueKind == JsonValueKind.Null ? null : property.GetString();
}
private static string[] ReadStringArray(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out JsonElement property) || property.ValueKind != JsonValueKind.Array)
return [];
return property.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Cast<string>()
.ToArray();
}
}
public sealed record MccFinalizationValidation(bool Accepted, string? ErrorText, MccFinalPayload? Payload)
{
public static MccFinalizationValidation Accept(MccFinalPayload payload) => new(true, null, payload);
public static MccFinalizationValidation Reject(string errorText) => new(false, errorText, null);
}

View file

@ -0,0 +1,63 @@
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Contracts;
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccGuidanceSource
{
public const string SourceToolName = "mcc_agent_guidance";
public const string CanonicalPromptName = "mcc_operator_guide";
public async Task<MccGuidanceBundle> LoadAsync(McpClient client, CancellationToken cancellationToken)
{
CallToolResult result = await client.CallToolAsync(SourceToolName, new Dictionary<string, object?>(), cancellationToken: cancellationToken);
MccNormalizedToolResult normalized = MccMcpJson.Normalize(result);
JsonElement data = normalized.Data ?? throw new InvalidOperationException("mcc_agent_guidance did not return data.");
string[] bestPractices = ReadStringArray(data, "bestPractices");
string[] exampleTitles = data.TryGetProperty("exampleScenarios", out JsonElement examples)
&& examples.ValueKind == JsonValueKind.Array
? examples.EnumerateArray()
.Select(example => example.TryGetProperty("title", out JsonElement title) ? title.GetString() : null)
.Where(title => !string.IsNullOrWhiteSpace(title))
.Cast<string>()
.ToArray()
: [];
MccCapabilityStatus capabilityStatus = data.TryGetProperty("capabilityStatus", out JsonElement capabilityJson)
? JsonSerializer.Deserialize<MccCapabilityStatus>(capabilityJson.GetRawText()) ?? new MccCapabilityStatus(false, false, false, false, false)
: new MccCapabilityStatus(false, false, false, false, false);
return new MccGuidanceBundle(
SourceToolName,
CanonicalPromptName,
SkillName: ReadString(data, "skillName") ?? "mcc-mcp-operator",
GuidanceVersion: ReadString(data, "guidanceVersion") ?? "unknown",
SystemPrompt: ReadString(data, "systemPrompt") ?? throw new InvalidOperationException("mcc_agent_guidance did not return systemPrompt."),
BestPractices: bestPractices,
ExampleScenarioTitles: exampleTitles,
CapabilityStatus: capabilityStatus);
}
private static string? ReadString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static string[] ReadStringArray(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.Array
? property.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Cast<string>()
.ToArray()
: [];
}
}

View file

@ -0,0 +1,86 @@
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccPromptComposer
{
private const string HarnessContract = """
You are operating Minecraft Console Client through MCC MCP tools.
Rules:
- Use tool results and the run-state summary as the source of truth.
- Execute tools sequentially.
- End the run only with mcc_submit_final.
- status=completed is valid only when no required verification obligations remain open.
- If the task is blocked or partial, say exactly what is verified and what remains unverified.
- Do not repeat the same failing stateful action with the same arguments.
- mcc_quit_client requires explicit user intent.
- Prefer structured high-level tools. Avoid escape hatches unless they are explicitly exposed and necessary.
""";
public List<object> Compose(MccRunState runState)
{
List<object> messages =
[
BuildSystemMessage(HarnessContract),
BuildSystemMessage(runState.Guidance.SystemPrompt),
BuildSystemMessage(BuildStateSummary(runState)),
.. runState.BaseConversationMessages
];
if (!string.IsNullOrWhiteSpace(runState.CompactionSummary))
{
messages.Add(BuildSystemMessage($"""
Older verified evidence summary
{runState.CompactionSummary}
"""));
}
foreach (object message in runState.ToolConversationMessages.TakeLast(12))
messages.Add(message);
return messages;
}
private static Dictionary<string, object?> BuildSystemMessage(string text)
{
return new Dictionary<string, object?>
{
["role"] = "system",
["content"] = text
};
}
private static string BuildStateSummary(MccRunState runState)
{
string evidence = runState.Evidence.Count == 0
? "- none yet"
: string.Join('\n', runState.Evidence.TakeLast(6).Select(record =>
$"- {record.Id} {record.ToolName}: {record.Summary}"));
string obligations = runState.OpenObligations.Count == 0
? "- none"
: string.Join('\n', runState.OpenObligations.Select(obligation =>
$"- {obligation.Id} {obligation.ToolName}/{obligation.Kind}: {obligation.Description}"));
string bestPractices = runState.Guidance.BestPractices.Length == 0
? "- use verified MCC state before claiming success"
: string.Join('\n', runState.Guidance.BestPractices.Take(4).Select(item => $"- {item}"));
return $"""
Current run state
- turnCount: {runState.TurnCount}
- toolCallCount: {runState.ToolCallCount}
- directAnswerAttempts: {runState.DirectAnswerAttempts}
- routedModel: {runState.RoutedModel ?? runState.ConfiguredModel}
- routedProvider: {runState.RoutedProvider ?? "unknown"}
Outstanding verification
{obligations}
Recent evidence
{evidence}
Guidance highlights
{bestPractices}
""";
}
}

View file

@ -0,0 +1,103 @@
using System.Text.Json;
using DebugTools.MccMcpWebPlayground.Contracts;
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccRunState
{
private int evidenceCounter;
private int obligationCounter;
public required string RunId { get; init; }
public required string UserRequest { get; init; }
public required List<object> BaseConversationMessages { get; init; }
public required string ConfiguredModel { get; init; }
public required MccGuidanceBundle Guidance { get; init; }
public DateTimeOffset StartedAtUtc { get; init; } = DateTimeOffset.UtcNow;
public List<object> ToolConversationMessages { get; } = [];
public List<MccEvidenceRecord> Evidence { get; } = [];
public List<MccToolExecutionRecord> ToolExecutions { get; } = [];
public List<MccVerificationObligation> VerificationObligations { get; } = [];
public string? CompactionSummary { get; set; }
public string? RoutedModel { get; set; }
public string? RoutedProvider { get; set; }
public int TurnCount { get; set; }
public int ToolCallCount { get; set; }
public int DirectAnswerAttempts { get; set; }
public string NextEvidenceId() => $"e{++evidenceCounter:0000}";
public string NextObligationId() => $"v{++obligationCounter:0000}";
public bool IsSoftFinish(MccWebHarnessOptions options, DateTimeOffset nowUtc)
{
TimeSpan elapsed = nowUtc - StartedAtUtc;
return (options.MaxTurns - TurnCount) <= options.SoftFinishRemainingTurns
|| (options.MaxToolCalls - ToolCallCount) <= options.SoftFinishRemainingToolCalls
|| (options.MaxWallClockSeconds - (int)elapsed.TotalSeconds) <= options.SoftFinishRemainingSeconds;
}
public bool IsHardStop(MccWebHarnessOptions options, DateTimeOffset nowUtc)
{
TimeSpan elapsed = nowUtc - StartedAtUtc;
return TurnCount >= options.MaxTurns
|| ToolCallCount >= options.MaxToolCalls
|| elapsed.TotalSeconds >= options.MaxWallClockSeconds;
}
public IReadOnlyList<MccVerificationObligation> OpenObligations =>
VerificationObligations.Where(obligation => !obligation.Cleared).ToArray();
}
public sealed record MccGuidanceBundle(
string SourceToolName,
string CanonicalPromptName,
string SkillName,
string GuidanceVersion,
string SystemPrompt,
string[] BestPractices,
string[] ExampleScenarioTitles,
MccCapabilityStatus CapabilityStatus);
public sealed class MccEvidenceRecord
{
public required string Id { get; init; }
public required string ToolName { get; init; }
public required string Summary { get; init; }
public required string RawText { get; init; }
public required bool IsError { get; init; }
public required bool Success { get; init; }
public string? ErrorCode { get; init; }
public JsonElement? Root { get; init; }
public JsonElement? Data { get; init; }
}
public sealed class MccToolExecutionRecord
{
public required string CallId { get; init; }
public required string ToolName { get; init; }
public required string ArgumentsJson { get; init; }
public required MccEvidenceRecord Evidence { get; init; }
}
public sealed class MccVerificationObligation
{
public required string Id { get; init; }
public required string ToolName { get; init; }
public required string Kind { get; init; }
public required string Description { get; init; }
public required string SourceEvidenceId { get; init; }
public JsonElement? Metadata { get; init; }
public bool Cleared { get; set; }
public string? ClearedByEvidenceId { get; set; }
}
public sealed record MccNormalizedToolResult(
string Text,
bool IsError,
bool Success,
string? ErrorCode,
string? Message,
JsonElement? Root,
JsonElement? Data);

View file

@ -0,0 +1,133 @@
using System.Collections.Frozen;
using System.Text.Json.Nodes;
using ModelContextProtocol.Client;
namespace DebugTools.MccMcpWebPlayground.Harness;
public enum MccToolRisk
{
ReadOnly,
Stateful,
Sensitive,
EscapeHatch
}
public sealed record MccToolProfile(
string Name,
MccToolRisk Risk,
bool VisibleByDefault,
bool RequiresExplicitUserIntent);
public sealed record MccToolCatalogEntry(McpClientTool Tool, MccToolProfile Profile);
public sealed class MccToolCatalog
{
public required Dictionary<string, MccToolCatalogEntry> ToolsByName { get; init; }
public required IReadOnlyList<object> ModelVisibleTools { get; init; }
}
public static class MccToolPolicy
{
private static readonly FrozenDictionary<string, MccToolProfile> Profiles =
new Dictionary<string, MccToolProfile>(StringComparer.OrdinalIgnoreCase)
{
["mcc_agent_guidance"] = new("mcc_agent_guidance", MccToolRisk.ReadOnly, false, false),
["mcc_inventory_window_action"] = new("mcc_inventory_window_action", MccToolRisk.EscapeHatch, false, false),
["mcc_run_internal_command"] = new("mcc_run_internal_command", MccToolRisk.EscapeHatch, false, false),
["mcc_quit_client"] = new("mcc_quit_client", MccToolRisk.Sensitive, true, true)
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
public static MccToolProfile GetProfile(string toolName)
{
return Profiles.TryGetValue(toolName, out MccToolProfile? profile)
? profile
: new MccToolProfile(toolName, MccToolRisk.Stateful, true, false);
}
public static MccToolCatalog BuildCatalog(IList<McpClientTool> tools, MccWebHarnessOptions options, object submitFinalTool)
{
Dictionary<string, MccToolCatalogEntry> toolsByName = tools.ToDictionary(
tool => tool.Name,
tool => new MccToolCatalogEntry(tool, GetProfile(tool.Name)),
StringComparer.OrdinalIgnoreCase);
List<object> visibleTools = [];
foreach (MccToolCatalogEntry entry in toolsByName.Values.OrderBy(entry => entry.Tool.Name, StringComparer.OrdinalIgnoreCase))
{
if (!IsVisible(entry.Profile, options))
continue;
visibleTools.Add(ToOpenRouterTool(entry.Tool, entry.Profile));
}
visibleTools.Add(submitFinalTool);
return new MccToolCatalog
{
ToolsByName = toolsByName,
ModelVisibleTools = visibleTools
};
}
public static bool RequiresExplicitUserIntent(string toolName)
{
return GetProfile(toolName).RequiresExplicitUserIntent;
}
public static bool HasExplicitUserIntent(string userRequest, string toolName)
{
if (!RequiresExplicitUserIntent(toolName))
return true;
string request = userRequest.Trim().ToLowerInvariant();
return toolName.Equals("mcc_quit_client", StringComparison.OrdinalIgnoreCase)
&& (request.Contains("quit mcc", StringComparison.Ordinal)
|| request.Contains("close mcc", StringComparison.Ordinal)
|| request.Contains("stop mcc", StringComparison.Ordinal)
|| request.Contains("exit mcc", StringComparison.Ordinal)
|| request.Contains("quit the client", StringComparison.Ordinal)
|| request.Contains("stop the client", StringComparison.Ordinal));
}
private static bool IsVisible(MccToolProfile profile, MccWebHarnessOptions options)
{
if (!profile.VisibleByDefault)
{
if (profile.Name.Equals("mcc_inventory_window_action", StringComparison.OrdinalIgnoreCase))
return options.ExposeInventoryWindowAction;
if (profile.Name.Equals("mcc_run_internal_command", StringComparison.OrdinalIgnoreCase))
return options.ExposeInternalCommandTool;
return false;
}
return true;
}
private static object ToOpenRouterTool(McpClientTool tool, MccToolProfile profile)
{
JsonNode parameters = JsonNode.Parse(tool.JsonSchema.GetRawText()) ?? new JsonObject
{
["type"] = "object",
["properties"] = new JsonObject()
};
string description = tool.Description ?? string.Empty;
if (profile.Risk == MccToolRisk.Sensitive)
description = $"{description} Requires explicit user intent.";
else if (profile.Risk == MccToolRisk.EscapeHatch)
description = $"{description} Advanced escape hatch; prefer higher-level tools first.";
return new Dictionary<string, object?>
{
["type"] = "function",
["function"] = new Dictionary<string, object?>
{
["name"] = tool.Name,
["description"] = description,
["parameters"] = parameters
}
};
}
}

View file

@ -0,0 +1,58 @@
namespace DebugTools.MccMcpWebPlayground.Harness;
public sealed class MccWebHarnessOptions
{
public const string SectionName = "MccWebHarness";
public string? Model { get; set; }
public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1";
public string McpEndpoint { get; set; } = "http://127.0.0.1:33333/mcp";
public int MaxTurns { get; set; } = 48;
public int MaxToolCalls { get; set; } = 120;
public int MaxWallClockSeconds { get; set; } = 240;
public int SoftFinishRemainingTurns { get; set; } = 3;
public int SoftFinishRemainingToolCalls { get; set; } = 8;
public int SoftFinishRemainingSeconds { get; set; } = 30;
public bool RequireProviderParameters { get; set; } = true;
public bool AllowFallbacks { get; set; }
public bool DisableParallelToolCalls { get; set; } = true;
public bool ExposeInventoryWindowAction { get; set; }
public bool ExposeInternalCommandTool { get; set; }
public string? ResolveModel()
{
return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_MODEL"), Model);
}
public string ResolveOpenRouterBaseUrl()
{
return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL"), OpenRouterBaseUrl)
?? "https://openrouter.ai/api/v1";
}
public string ResolveMcpEndpoint()
{
return FirstNonEmpty(Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT"), McpEndpoint)
?? "http://127.0.0.1:33333/mcp";
}
public string? ResolveMcpAuthToken()
{
return Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN");
}
public string? ResolveApiKey()
{
return Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
}
public bool HasApiKeyConfigured()
{
return !string.IsNullOrWhiteSpace(ResolveApiKey());
}
private static string? FirstNonEmpty(params string?[] candidates)
{
return candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate))?.Trim();
}
}

View file

@ -0,0 +1,200 @@
using System.Text;
using System.Text.Json;
using System.Reflection;
using DebugTools.MccMcpWebPlayground.Harness;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
namespace DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
public sealed class MccMcpSessionFactory
{
private readonly MccWebHarnessOptions options;
public MccMcpSessionFactory(IOptions<MccWebHarnessOptions> options)
{
this.options = options.Value;
}
public async Task<McpClient> CreateAsync(CancellationToken cancellationToken)
{
string endpoint = options.ResolveMcpEndpoint();
string? token = options.ResolveMcpAuthToken();
return await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri(endpoint),
TransportMode = HttpTransportMode.AutoDetect,
AdditionalHeaders = string.IsNullOrWhiteSpace(token)
? null
: new Dictionary<string, string>
{
["Authorization"] = $"Bearer {token}"
}
}), cancellationToken: cancellationToken);
}
}
public static class MccMcpJson
{
public static MccNormalizedToolResult Normalize(CallToolResult result)
{
JsonElement? structuredRoot = TryReadStructuredContent(result);
string text = ReadToolResultText(result, structuredRoot);
try
{
using JsonDocument document = JsonDocument.Parse(text);
JsonElement parsedRoot = document.RootElement.Clone();
JsonElement root = ShouldPreferStructuredRoot(parsedRoot, structuredRoot)
? structuredRoot!.Value
: parsedRoot;
JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement)
? dataElement.Clone()
: ShouldTreatRootAsData(root) ? root.Clone() : structuredRoot;
bool success = root.TryGetProperty("success", out JsonElement successElement)
? successElement.ValueKind != JsonValueKind.False
: result.IsError != true;
string? errorCode = root.TryGetProperty("errorCode", out JsonElement errorCodeElement) && errorCodeElement.ValueKind == JsonValueKind.String
? errorCodeElement.GetString()
: null;
string? message = root.TryGetProperty("message", out JsonElement messageElement) && messageElement.ValueKind == JsonValueKind.String
? messageElement.GetString()
: null;
bool isError = result.IsError == true || !success || !string.IsNullOrWhiteSpace(errorCode);
return new MccNormalizedToolResult(text, isError, success, errorCode, message, root, data);
}
catch
{
bool isError = result.IsError == true;
return new MccNormalizedToolResult(text, isError, !isError, null, null, structuredRoot, structuredRoot);
}
}
private static string ReadToolResultText(CallToolResult result, JsonElement? structuredRoot)
{
if (result.Content is null)
return structuredRoot?.GetRawText() ?? (result.IsError == true ? "{\"success\":false}" : "{\"success\":true}");
StringBuilder builder = new();
foreach (ContentBlock block in result.Content)
{
if (block is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text))
{
if (builder.Length > 0)
builder.Append('\n');
builder.Append(text.Text);
}
}
return builder.Length > 0
? builder.ToString()
: structuredRoot?.GetRawText()
?? JsonSerializer.Serialize(new { success = result.IsError != true, isError = result.IsError });
}
private static JsonElement? TryReadStructuredContent(CallToolResult result)
{
PropertyInfo? property = typeof(CallToolResult).GetProperty("StructuredContent", BindingFlags.Instance | BindingFlags.Public);
if (property?.GetValue(result) is not { } value)
return null;
return value switch
{
JsonElement json when json.ValueKind != JsonValueKind.Undefined && json.ValueKind != JsonValueKind.Null => json.Clone(),
JsonDocument document => document.RootElement.Clone(),
string text when !string.IsNullOrWhiteSpace(text) => TryParseJson(text),
_ => TrySerializeToJson(value)
};
}
private static JsonElement? TrySerializeToJson(object value)
{
try
{
return JsonSerializer.SerializeToElement(value);
}
catch
{
return null;
}
}
private static JsonElement? TryParseJson(string text)
{
try
{
using JsonDocument document = JsonDocument.Parse(text);
return document.RootElement.Clone();
}
catch
{
return null;
}
}
private static bool ShouldPreferStructuredRoot(JsonElement parsedRoot, JsonElement? structuredRoot)
{
if (structuredRoot is null)
return false;
if (parsedRoot.ValueKind != JsonValueKind.Object)
return true;
return !parsedRoot.EnumerateObject().Any(property =>
!property.NameEquals("success") &&
!property.NameEquals("isError"));
}
private static bool ShouldTreatRootAsData(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
return false;
return root.EnumerateObject().Any(property =>
!property.NameEquals("success") &&
!property.NameEquals("isError") &&
!property.NameEquals("errorCode") &&
!property.NameEquals("message"));
}
}
public static class MccJsonArguments
{
public static Dictionary<string, object?> Parse(string rawJson)
{
try
{
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(rawJson) ? "{}" : rawJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
return new Dictionary<string, object?>();
Dictionary<string, object?> values = new(StringComparer.OrdinalIgnoreCase);
foreach (JsonProperty property in document.RootElement.EnumerateObject())
values[property.Name] = Convert(property.Value);
return values;
}
catch
{
return new Dictionary<string, object?>();
}
}
private static object? Convert(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Null => null,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => element.TryGetInt64(out long i64)
? i64
: element.TryGetDouble(out double d) ? d : element.GetRawText(),
JsonValueKind.String => element.GetString(),
JsonValueKind.Array => element.EnumerateArray().Select(Convert).ToArray(),
JsonValueKind.Object => element.EnumerateObject().ToDictionary(property => property.Name, property => Convert(property.Value)),
_ => element.GetRawText()
};
}
}

Some files were not shown because too many files have changed in this diff Show more