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();