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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .claude/skills/swarm-orchestrator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,11 +306,16 @@ The worker must:
4. not merge other streams;
5. not claim the overall feature is complete.

### Base safety
### Worktrees (mandatory)

Each worker must verify its worktree/branch begins from the specified base.
Every stream that writes to the repository works in its own git worktree, created by the orchestrator before dispatch. Workers never write in the operator's checkout.

If stale, correct it before implementation and report that correction.
- The orchestrator creates each worktree explicitly from the locked base commit, outside the repository directory (e.g. a scratch directory):
`git worktree add -b swarm/<stream> <absolute path> <base SHA>`
Do not rely on agent-runtime automatic worktree isolation: it may branch from a different commit than the locked base and may remove the worktree when a worker pauses without changes.
- The brief names the absolute worktree path and branch. The worker works only there, using absolute paths.
- Before writing, the worker verifies `HEAD` equals the specified base SHA (or a descendant the brief allows). If it is stale, or the worktree is missing, the worker stops and reports. It never falls back to another directory, including the operator's checkout.
- Worktrees persist until the orchestrator has accepted composition of their streams. Then the orchestrator removes them (`git worktree remove`) and deletes their `swarm/*` branches.

---

Expand Down Expand Up @@ -465,7 +470,7 @@ The change is complete only when the orchestrator can demonstrate all of the fol
7. Migration/upgrade behaviour meets the locked specification.
8. No known resource grows without an intentional retention policy.
9. Documentation describes observed/current behaviour rather than inferred behaviour.
10. No agent worktree, scratch file, accidental generated output, or orchestration residue is included.
10. No agent worktree, scratch file, accidental generated output, or orchestration residue is included, and every swarm worktree and `swarm/*` branch has been removed.
11. The final diff contains no unexplained scope expansion.
12. The orchestrator is satisfied that the **specification**, not merely the implementation plan, has been met.

Expand Down Expand Up @@ -553,7 +558,7 @@ When invoked with a change request:
4. Continue until the Zero-Ambiguity Gate is satisfied.
5. Present the LOCKED SPECIFICATION and request explicit approval.
6. After approval, show the implementation DAG and model routing.
7. Dispatch implementation streams.
7. Create a worktree per writing stream from the locked base, then dispatch implementation streams into them.
8. Integrate and fan out validation.
9. Replan defects into further implementation rounds.
10. Stop only at the Completion Gate.
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ The project is .NET 10, and uses Microsoft's System.CommandLine library for buil
- `pbtk gpt train --source corpus.txt --out ./.gpt --loss-graph ./loss.html`
- `pbtk gpt train --source corpus.txt --out ./.gpt --validation-split 0.1 --eval-interval 50`
- `pbtk gpt complete --model ./.gpt --prompt "ROMEO:" --max-tokens 200`
- `pbtk config llm --model claude-sonnet-5-5`
- `pbtk config llm chat --model gpt-5-nano`
- `pbtk config llm chat`
- `pbtk config llm chat --unset model`

### Testing Requirements

Expand Down Expand Up @@ -218,6 +222,13 @@ The project follows a topic-based command architecture with a component-per-comm
- **Commands/**: Contains topic command definitions, each topic has a static `Create()` method that registers sub-actions
- **Components/**: Contains the core business logic implementations that commands delegate to

### Per-user config

- `ConfigBootstrap.AddConfigTopic` in `Program.cs` must stay after all topic registrations, because the `config` tree is built from the finished command tree.
- Every non-required option is configurable automatically. No per-option wiring is needed.
- To exclude an option, mark it with `.WithImmutableDefault()` (namespace `Pixelbadger.Toolkit.CommandLine`). Use this for secrets and destructive or session-identifying flags.
- The option key `unset` is reserved for the `config` command's `--unset` option.

## Component-Per-Command Pattern

**IMPORTANT**: Each command action should have its own dedicated component class. This enforces single responsibility principle and improves maintainability.
Expand Down Expand Up @@ -267,6 +278,7 @@ Available topics and actions:
- **web**: serve-html
- **llm**: chat, translate, ocaaar, corpospeak, generate-image
- **gpt**: train, complete
- **config**: mirrors every topic/group/action; sets, shows (bare) and unsets (--unset) per-user option defaults

## Dependencies

Expand All @@ -279,6 +291,7 @@ Available topics and actions:
- Microsoft.Extensions.DependencyInjection
- Microsoft.Extensions.AI (for AI abstractions)
- Microsoft.Extensions.AI.OpenAI (for OpenAI integration)
- Dapper (config store data access)

## Environment Variables

Expand Down
223 changes: 223 additions & 0 deletions Pixelbadger.Toolkit.Tests/ConfigCommandIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
using FluentAssertions;
using Microsoft.Data.Sqlite;

namespace Pixelbadger.Toolkit.Tests;

public class ConfigCommandIntegrationTests : IDisposable
{
private readonly string _configHome;
private readonly string _workingDir;

public ConfigCommandIntegrationTests()
{
_configHome = Path.Combine(Path.GetTempPath(), "pbtk-cfg-it-" + Guid.NewGuid().ToString("N"));
_workingDir = Path.Combine(_configHome, "work");
Directory.CreateDirectory(_workingDir);
}

public void Dispose()
{
try
{
if (Directory.Exists(_configHome))
Directory.Delete(_configHome, true);
}
catch (IOException)
{
}
}

private string DbPath => Path.Combine(_configHome, "pbtk", "config.db");

private Task<(int ExitCode, string StandardOutput, string StandardError)> Run(params string[] args) =>
ToolkitProcess.RunWithConfigHomeAsync(_workingDir, _configHome, args);

// Returns the help entry (first line plus wrapped continuation lines) for the given option.
private static string OptionEntry(string help, string option)
{
var lines = help.Replace("\r", "").Split('\n');
var start = Array.FindIndex(lines, l => l.TrimStart().StartsWith(option + " ") || l.TrimStart() == option);
start.Should().BeGreaterThanOrEqualTo(0, $"help should list {option}:\n{help}");
var entry = new List<string> { lines[start] };
for (var i = start + 1; i < lines.Length; i++)
{
var t = lines[i].TrimStart();
if (t.Length == 0 || t.StartsWith("-") || !char.IsWhiteSpace(lines[i][0]) || (lines[i].Length - t.Length) <= (lines[start].Length - lines[start].TrimStart().Length))
break;
entry.Add(lines[i]);
}
return string.Join(" ", entry.Select(l => l.Trim()));
}

private async Task<string> HelpOf(params string[] args)
{
var r = await Run(args.Append("--help").ToArray());
r.ExitCode.Should().Be(0, r.StandardOutput + r.StandardError);
return r.StandardOutput;
}

private void Exec(string sql)
{
Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
using var conn = new SqliteConnection($"Data Source={DbPath};Pooling=False");
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}

private static readonly string[] LlmActions = { "chat", "translate", "ocaaar", "corpospeak", "generate-image" };

[Fact]
public async Task ConfigLlmModel_ShouldSetDefaultForAllLlmActions_WhenSetAtTopicLevel()
{
var set = await Run("config", "llm", "--model", "claude-sonnet-5-5");
set.ExitCode.Should().Be(0, set.StandardOutput + set.StandardError);

foreach (var action in LlmActions)
{
var help = await HelpOf("llm", action);
OptionEntry(help, "--model").Should().Contain("[default: claude-sonnet-5-5]", action);
}
}

[Fact]
public async Task ConfigLlmChatModel_ShouldOverrideOnlyChat_WhenSetAtActionLevelAndUnsetRestoresTopic()
{
(await Run("config", "llm", "--model", "claude-sonnet-5-5")).ExitCode.Should().Be(0);
(await Run("config", "llm", "chat", "--model", "X")).ExitCode.Should().Be(0);

OptionEntry(await HelpOf("llm", "chat"), "--model").Should().Contain("[default: X]");
OptionEntry(await HelpOf("llm", "translate"), "--model").Should().Contain("[default: claude-sonnet-5-5]");

var show = await Run("config", "llm", "chat");
show.ExitCode.Should().Be(0);
show.StandardOutput.Should().Contain("--model = X (from: llm chat)");

(await Run("config", "llm", "chat", "--unset", "model")).ExitCode.Should().Be(0);

OptionEntry(await HelpOf("llm", "chat"), "--model").Should().Contain("[default: claude-sonnet-5-5]");
var show2 = await Run("config", "llm", "chat");
show2.StandardOutput.Should().Contain("(from: llm)");
}

[Fact]
public async Task ConfigLlmProvider_ShouldFailAndPersistNothing_WhenValueIsInvalid()
{
var bad = await Run("config", "llm", "--provider", "bogus");
bad.ExitCode.Should().NotBe(0);
(bad.StandardOutput + bad.StandardError).Should().Contain("bogus");

var show = await Run("config", "llm");
show.StandardOutput.Should().Contain("No configured defaults for llm");

if (File.Exists(DbPath))
{
using var conn = new SqliteConnection($"Data Source={DbPath};Pooling=False");
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE name = 'llm'";
Convert.ToInt32(cmd.ExecuteScalar()).Should().Be(0);
}
}

[Fact]
public async Task ConfigGpt_ShouldApplyDefaultsOnlyToActionsWithTheOption_WhenSet()
{
(await Run("config", "gpt", "train", "--steps", "7")).ExitCode.Should().Be(0);
OptionEntry(await HelpOf("gpt", "train"), "--steps").Should().Contain("[default: 7]");
(await HelpOf("gpt", "complete")).Should().NotContain("--steps");

(await Run("config", "gpt", "--seed", "42")).ExitCode.Should().Be(0);
OptionEntry(await HelpOf("gpt", "train"), "--seed").Should().Contain("[default: 42]");
OptionEntry(await HelpOf("gpt", "complete"), "--seed").Should().Contain("[default: 42]");
}

[Fact]
public async Task ConfigHelp_ShouldOmitImmutableOptions_WhenShown()
{
(await HelpOf("config", "llm", "chat")).Should().NotContain("--session-id");
(await HelpOf("config", "llm", "generate-image")).Should().NotContain("--overwrite");
(await HelpOf("config", "gpt", "train")).Should().NotContain("--resume");
(await HelpOf("config", "oauth", "profile", "update")).Should().NotContain("--client-secret");
(await HelpOf("config", "oauth", "profile", "add")).Should().NotContain("--client-secret");

var r = await Run("config", "llm", "chat", "--session-id", "5");
r.ExitCode.Should().NotBe(0);
}

[Fact]
public async Task WebServeHtmlHelp_ShouldShowConfiguredPort_WhenConfigured()
{
(await Run("config", "web", "serve-html", "--port", "9999")).ExitCode.Should().Be(0);
OptionEntry(await HelpOf("web", "serve-html"), "--port").Should().Contain("[default: 9999]");
}

[Fact]
public async Task GptComplete_ShouldFailOnlyItsUsers_WhenStoredValueIsCorrupt()
{
Exec("CREATE TABLE IF NOT EXISTS \"gpt\" (action TEXT NOT NULL, option TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (action, option))");
Exec("INSERT INTO \"gpt\" (action, option, value) VALUES ('complete', 'max-tokens', '[\"abc\"]')");

var r = await Run("gpt", "complete", "--prompt", "x", "--model", Path.Combine(_configHome, "no-such-model"));
r.ExitCode.Should().NotBe(0);
(r.StandardOutput + r.StandardError).Should().Contain("Configured default for gpt complete --max-tokens is invalid");

(await Run("gpt", "train", "--help")).ExitCode.Should().Be(0);
}

[Fact]
public async Task LlmChatHelp_ShouldWarnAndSucceed_WhenStoredEntryIsUnknown()
{
Exec("CREATE TABLE IF NOT EXISTS \"llm\" (action TEXT NOT NULL, option TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (action, option))");
Exec("INSERT INTO \"llm\" (action, option, value) VALUES ('', 'no-such-option', '[\"x\"]')");

var r = await Run("llm", "chat", "--help");
r.ExitCode.Should().Be(0);
r.StandardError.Should().Contain("Warning: ignoring unknown config entry llm --no-such-option");
}

[Fact]
public async Task Store_ShouldCreateOnlyTouchedTopicTables_WhenUsed()
{
(await Run("config", "gpt", "--seed", "1")).ExitCode.Should().Be(0);
(await Run("llm", "chat", "--help")).ExitCode.Should().Be(0);

using var conn = new SqliteConnection($"Data Source={DbPath};Pooling=False");
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'";
var tables = new List<string>();
using (var reader = cmd.ExecuteReader())
while (reader.Read())
tables.Add(reader.GetString(0));

tables.Should().Contain("gpt");
tables.Should().NotContain("llm");
}

[Fact]
public async Task LlmChatHelp_ShouldReportErrorAndExitOne_WhenDatabaseIsUnreadable()
{
Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
var junk = new byte[4096];
new Random(1234).NextBytes(junk);
File.WriteAllBytes(DbPath, junk);

var r = await Run("llm", "chat", "--help");
r.ExitCode.Should().Be(1);
r.StandardOutput.Should().Contain("Error:");
r.StandardOutput.Should().Contain(DbPath);
}

[Fact]
public async Task Config_ShouldPrintTopicHelpWithoutValues_WhenNoTopicGiven()
{
(await Run("config", "llm", "--model", "secret-model-name")).ExitCode.Should().Be(0);

var r = await Run("config");
var output = r.StandardOutput + r.StandardError;
output.Should().Contain("llm");
output.Should().NotContain("secret-model-name");
}
}
Loading
Loading