From 1362c712f2112b5f5d4878a6f49901ddd5206a67 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sun, 31 May 2026 14:55:23 +0200 Subject: [PATCH] Preserve MCP artifact content on metadata updates --- CLAUDE.md | 14 ++ .../Concerns/WorksWithEvolveArtifacts.php | 30 +++ app/Mcp/Tools/UpsertArtifact.php | 1 + docs/agents/domain.md | 18 ++ docs/agents/issue-tracker.md | 19 ++ docs/agents/triage-labels.md | 13 ++ tests/Feature/EvolveMcpServerTest.php | 181 ++++++++++++++++++ 7 files changed, 276 insertions(+) create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/CLAUDE.md b/CLAUDE.md index d347878..f73ccea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,17 @@ # Claude Instructions See [AGENTS.md](./AGENTS.md). + +## Agent skills + +### Issue tracker + +Issues and PRDs are tracked in GitHub Issues for `datashaman/evolve`. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Triage uses the default labels: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, and `wontfix`. See `docs/agents/triage-labels.md`. + +### Domain docs + +This is a single-context repo using root `CONTEXT.md` and root `docs/adr/` when present. See `docs/agents/domain.md`. diff --git a/app/Mcp/Tools/Concerns/WorksWithEvolveArtifacts.php b/app/Mcp/Tools/Concerns/WorksWithEvolveArtifacts.php index d35662f..10dcdd1 100644 --- a/app/Mcp/Tools/Concerns/WorksWithEvolveArtifacts.php +++ b/app/Mcp/Tools/Concerns/WorksWithEvolveArtifacts.php @@ -70,6 +70,36 @@ protected function normalizedArtifact(array $payload): array ]; } + protected function hydrateOmittedContentFields(array $artifact, ?array $existing, array $payload): array + { + foreach (['php', 'blade', 'style'] as $field) { + if (array_key_exists($field, $payload)) { + continue; + } + + $artifact[$field] = $existing[$field] ?? $this->defaultContentField((string) $artifact['kind'], $field); + } + + return $artifact; + } + + protected function defaultContentField(string $kind, string $field): string + { + if ($field === 'php' && in_array($kind, ['component', 'form', 'page'], true)) { + return "use Livewire\\Component;\n\nnew class extends Component {\n //\n};"; + } + + if ($field === 'blade') { + return match ($kind) { + 'layout', 'snippet' => '{{ $slot }}', + 'component', 'form', 'page', 'view' => '
', + default => '', + }; + } + + return ''; + } + protected function idFromPath(string $path): string { $path = preg_replace('#\.(blade\.php|css)$#', '', str_replace('\\', '/', trim($path))); diff --git a/app/Mcp/Tools/UpsertArtifact.php b/app/Mcp/Tools/UpsertArtifact.php index 0219f03..7eedcf2 100644 --- a/app/Mcp/Tools/UpsertArtifact.php +++ b/app/Mcp/Tools/UpsertArtifact.php @@ -40,6 +40,7 @@ public function handle(Request $request, EvolveLibrary $library): ResponseFactor $artifact = $this->normalizedArtifact($data); $existingId = (string) ($data['id'] ?? $artifact['id']); $existing = $this->artifact($library, $data['kind'], $existingId); + $artifact = $this->hydrateOmittedContentFields($artifact, $existing, $data); $dryRun = (bool) ($data['dry_run'] ?? true); if (! $dryRun) { diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..d99f2e2 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,18 @@ +# Domain Docs + +This is a single-context repo. + +## Before exploring, read these when present + +- `CONTEXT.md` at the repo root +- `docs/adr/` for architectural decisions relevant to the area being changed + +If these files do not exist, proceed silently. Do not create them upfront; producer skills such as `grill-with-docs` create them when project language or decisions are clarified. + +## Use project vocabulary + +When naming domain concepts in issues, plans, hypotheses, tests, or refactors, use the terms defined in `CONTEXT.md` if present. + +## Flag ADR conflicts + +If proposed work contradicts an existing ADR, call that out explicitly before proceeding. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..077d804 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,19 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues in `datashaman/evolve`. Use the GitHub connector or the `gh` CLI for operations. + +## Conventions + +- Create issues in GitHub Issues for `datashaman/evolve`. +- Read issues with comments and labels before triage. +- Comment on issues when applying triage outcomes. +- Apply and remove labels using the repo's configured triage label vocabulary. +- Close issues only when the triage outcome calls for it. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue in `datashaman/evolve`. + +## When a skill says "fetch the relevant ticket" + +Fetch the GitHub issue, including body, comments, labels, reporter, and dates. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..0806b2f --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,13 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role, use the corresponding label string from this table. diff --git a/tests/Feature/EvolveMcpServerTest.php b/tests/Feature/EvolveMcpServerTest.php index 5552f06..4b6d375 100644 --- a/tests/Feature/EvolveMcpServerTest.php +++ b/tests/Feature/EvolveMcpServerTest.php @@ -323,6 +323,187 @@ public function test_mcp_artifact_tools_manage_route_backed_views(): void $this->assertSame('

Landing

', trim(File::get(resource_path('views/marketing/landing.blade.php')))); } + public function test_mcp_metadata_only_updates_preserve_existing_view_content(): void + { + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'view', + 'name' => 'Landing', + 'path' => 'resources/views/marketing/landing.blade.php', + 'route' => '/landing', + 'route_name' => 'landing', + 'blade' => '

Landing

', + 'dry_run' => false, + ])->assertOk(); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'view', + 'id' => 'marketing/landing', + 'name' => 'Landing', + 'path' => 'resources/views/marketing/landing.blade.php', + 'route' => '/renamed-landing', + 'route_name' => 'renamed.landing', + ])->assertOk() + ->assertStructuredContent(fn ($json) => $json + ->where('dry_run', true) + ->where('artifact.blade', File::get(resource_path('views/marketing/landing.blade.php'))) + ->etc() + ); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'view', + 'id' => 'marketing/landing', + 'name' => 'Landing', + 'path' => 'resources/views/marketing/landing.blade.php', + 'route' => '/renamed-landing', + 'route_name' => 'renamed.landing', + 'dry_run' => false, + ])->assertOk(); + + $this->assertSame('

Landing

', trim(File::get(resource_path('views/marketing/landing.blade.php')))); + } + + public function test_mcp_metadata_only_updates_preserve_existing_sfc_content(): void + { + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'page', + 'name' => 'Landing', + 'path' => 'resources/views/pages/landing.blade.php', + 'route' => '/landing', + 'route_name' => 'landing', + 'php' => $this->componentPhp(), + 'blade' => '
Landing
', + 'style' => '.landing { color: red; }', + 'dry_run' => false, + ])->assertOk(); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'page', + 'id' => 'landing', + 'name' => 'Landing', + 'path' => 'resources/views/pages/landing.blade.php', + 'route' => '/home', + 'route_name' => 'pages.home', + ])->assertOk() + ->assertStructuredContent(fn ($json) => $json + ->where('dry_run', true) + ->where('artifact.php', $this->componentPhp()) + ->where('artifact.blade', '
Landing
') + ->where('artifact.style', '.landing { color: red; }') + ->etc() + ); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'page', + 'id' => 'landing', + 'name' => 'Landing', + 'path' => 'resources/views/pages/landing.blade.php', + 'route' => '/home', + 'route_name' => 'pages.home', + 'dry_run' => false, + ])->assertOk(); + + $source = File::get(resource_path('views/pages/landing.blade.php')); + + $this->assertStringContainsString($this->componentPhp(), $source); + $this->assertStringContainsString('
Landing
', $source); + $this->assertStringContainsString('.landing { color: red; }', $source); + } + + public function test_mcp_metadata_only_updates_preserve_layout_snippet_and_style_content(): void + { + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'layout', + 'name' => 'Marketing', + 'path' => 'resources/views/layouts/marketing.blade.php', + 'blade' => '
{{ $slot }}
', + 'style' => '.marketing { display: grid; }', + 'dry_run' => false, + ])->assertOk(); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'snippet', + 'name' => 'Badge', + 'path' => 'resources/views/snippets/badge.blade.php', + 'blade' => 'Badge', + 'dry_run' => false, + ])->assertOk(); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'style', + 'name' => 'Theme', + 'path' => 'resources/css/theme.css', + 'style' => ':root { --accent: teal; }', + 'dry_run' => false, + ])->assertOk(); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'layout', + 'id' => 'marketing', + 'name' => 'Marketing Layout', + 'path' => 'resources/views/layouts/marketing.blade.php', + 'dry_run' => false, + ])->assertOk(); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'snippet', + 'id' => 'badge', + 'name' => 'Badge Snippet', + 'path' => 'resources/views/snippets/badge.blade.php', + 'dry_run' => false, + ])->assertOk(); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'style', + 'id' => 'theme', + 'name' => 'Theme Tokens', + 'path' => 'resources/css/theme.css', + 'dry_run' => false, + ])->assertOk(); + + $this->assertSame('
{{ $slot }}
', trim(File::get(resource_path('views/layouts/marketing.blade.php')))); + $this->assertSame('.marketing { display: grid; }', trim(File::get(resource_path('css/layouts/marketing.css')))); + $this->assertSame('Badge', trim(File::get(resource_path('views/snippets/badge.blade.php')))); + $this->assertSame(':root { --accent: teal; }', trim(File::get(resource_path('css/theme.css')))); + } + + public function test_mcp_upsert_distinguishes_omitted_content_from_explicit_empty_content(): void + { + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'view', + 'name' => 'Landing', + 'path' => 'resources/views/marketing/landing.blade.php', + 'route' => '/landing', + 'route_name' => 'landing', + 'blade' => '

Landing

', + 'dry_run' => false, + ])->assertOk(); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'view', + 'id' => 'marketing/landing', + 'name' => 'Landing', + 'path' => 'resources/views/marketing/landing.blade.php', + 'route' => '/landing', + 'route_name' => 'landing', + 'blade' => '', + 'dry_run' => false, + ])->assertOk(); + + $this->assertSame('', trim(File::get(resource_path('views/marketing/landing.blade.php')))); + + EvolveServer::tool(UpsertArtifact::class, [ + 'kind' => 'component', + 'name' => 'Empty Defaults', + 'path' => 'resources/views/components/empty-defaults.blade.php', + 'dry_run' => false, + ])->assertOk(); + + $source = File::get(resource_path('views/components/empty-defaults.blade.php')); + + $this->assertStringContainsString('use Livewire\Component;', $source); + $this->assertStringContainsString('
', $source); + } + public function test_content_tools_list_rows_and_upsert_granularly(): void { $fixture = $this->fixtureModelClass();