"""Materialise a Claude-native operator workspace from the shipped harness data.
Per ADR R4 the workspace materialiser is DEMOTED from the primary delivery
vehicle to an optional Claude-native mirror: the operating layer reaches an
arbitrary MCP client through the console's floor tool, resources, and prompts,
and this materialiser is the Claude-specific enhancement that lays the same
shipped harness out in the layout a Claude Code project loads natively.
The emitted layout is the Claude-native convention for an end-user project
directory - never the repository's own vaultspec developer ``.claude/`` tree:
- workflow skills -> ``.claude/skills/<name>/SKILL.md`` (plus each skill's
``reference/`` progressive-disclosure material), the standard skill layout;
- tax-advisor personas -> ``.claude/agents/<name>.md``, Claude Code subagent
definitions;
- operator operating rules -> ``.claude/rules/<name>.md``, aggregated by a root
``CLAUDE.md`` that ``@``-imports each rule so the always-on operating contract
loads at session start.
This is a REPLACEMENT of the prior flat ``{rules,personas,skills}/`` layout, not
an addition (the no-legacy discipline): the flat layout is gone. It writes only
the reviewed harness markdown (no secrets, no tax data) and computes no value.
"""
from __future__ import annotations
import json
from collections.abc import Sequence
from importlib import metadata as _metadata
from importlib.resources.abc import Traversable
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
from .. import __version__
from ..core.external_constants import UTF_8_ENCODING as _UTF_8
from . import harness_root, iter_operator_rules, iter_personas
_STRICT_FROZEN = ConfigDict(frozen=True, strict=True, validate_assignment=True, extra="forbid")
_CLAUDE_DIR = ".claude"
_RULES_SUBDIR = "rules"
_AGENTS_SUBDIR = "agents"
_SKILLS_SUBDIR = "skills"
_SKILL_ENTRYPOINT = "SKILL.md"
_CLAUDE_MEMORY_FILE = "CLAUDE.md"
# --- Claude plugin layout -------------------------------------------------
#
# The plugin layout target (ADR "Plugin generation") re-materialises the SAME
# authored harness source as a one-click Claude plugin: a ``.claude-plugin/``
# manifest, a top-level ``skills/`` and ``agents/`` tree, and an ``.mcp.json``
# declaring the stdio ``aeat-mcp`` server. The manifest schema is the one the
# live ``claude plugin validate --strict`` oracle accepts; every field name here
# is verified against that validator, not trusted from documentation.
_PLUGIN_DIR = ".claude-plugin"
_PLUGIN_MANIFEST = "plugin.json"
_PLUGIN_NAME = "aeat"
_PLUGIN_DISPLAY_NAME = "AEAT Spanish tax assistant"
# Distilled from the mcpb manifest one-liner; keeps the never-files-live boundary
# stated on the operator-facing surface.
_PLUGIN_DESCRIPTION = (
"Operate the aeat Spanish-tax CLI: grounded search over the bundled BOE/AEAT "
"legal corpus, situation-keyed guided workflows, and gated execution that "
"never files to AEAT. The server advertises an orientation core by default "
"(overview + contract + search/execute); set the surface option to 'full' to "
"advertise every verb up front."
)
_PLUGIN_AUTHOR_NAME = "AEAT tax assistant project"
_PLUGIN_LICENSE = "Apache-2.0"
_PLUGIN_KEYWORDS = ("tax", "aeat", "spain", "irpf", "iva", "modelo")
_PLUGIN_SCHEMA = "https://anthropic.com/claude-code/plugin.schema.json"
# The plugin's stdio MCP server. ``uvx`` boots ``aeat-mcp`` from the published
# wheel pinned to the plugin's own version (D2a), so a machine with ``uv`` but no
# project checkout runs the exact server the plugin release was cut against. The
# PyPI distribution name (``aeat-cli``, the operator's registered project) differs
# from the plugin name (``aeat``) and the import package (``aeat``); the pin MUST
# carry the ``[agent]`` extra — the MCP SDK the server runs on rides that extra,
# and a bare install makes ``aeat-mcp`` refuse with the install hint. The active
# persona is wired from the ``userConfig`` persona option through the documented
# ``${user_config.persona}`` interpolation; the server validates and refuses an
# unknown persona (server-side validation is the refusal surface).
_MCP_CONFIG = ".mcp.json"
_MCP_SERVER_NAME = "aeat"
_MCP_LAUNCHER = "uvx"
_MCP_CONSOLE_SCRIPT = "aeat-mcp"
_PYPI_DISTRIBUTION = "aeat-cli"
_MCP_PERSONA_ENV = "AEAT_MCP_PERSONA"
_MCP_PERSONA_INTERPOLATION = "${user_config.persona}"
# The advertised-tool-surface toggle (ADR mcp-progressive-discovery P1). ``core``
# (default) advertises only the orientation slice; ``full`` restores the flat
# per-verb surface. Wired from the ``userConfig`` surface option; the server
# validates the value and refuses an unknown one.
_MCP_SURFACE_ENV = "AEAT_MCP_SURFACE"
_MCP_SURFACE_INTERPOLATION = "${user_config.surface}"
# --- Claude marketplace layout --------------------------------------------
#
# The marketplace layout target (ADR "Marketplace") emits the git-repo content
# a dedicated public marketplace repository serves: a ``.claude-plugin/``
# ``marketplace.json`` (marketplace name ``neve``) listing the aeat plugin plus
# the plugin tree it points
# at, materialised UNDER the marketplace root at ``plugins/aeat`` via the same
# ``materialise_plugin`` emitter, so the marketplace manifest and the plugin it
# serves cannot drift. Every field name here is the one the live
# ``claude plugin validate --strict`` oracle accepts for a marketplace manifest;
# note the validator checks the manifest shape only and does NOT resolve the
# ``plugins[].source`` path, so the generator materialises the pointed-at plugin
# itself rather than trusting the manifest alone.
_MARKETPLACE_MANIFEST = "marketplace.json"
# The marketplace NAME is the ecosystem namespace users address plugins under
# (``<plugin>@neve``), independent of the repo it is served from; kebab-case
# (lowercase) is required by the claude.ai marketplace sync.
_MARKETPLACE_NAME = "neve"
_MARKETPLACE_DESCRIPTION = "Neve plugin marketplace — Claude plugins including the aeat Spanish-tax assistant."
_MARKETPLACE_OWNER_NAME = _PLUGIN_AUTHOR_NAME
_MARKETPLACE_PLUGINS_SUBDIR = "plugins"
# The relative source the marketplace manifest points at, resolved from the
# marketplace repo root (the directory holding ``.claude-plugin/``).
_MARKETPLACE_PLUGIN_SOURCE = f"./{_MARKETPLACE_PLUGINS_SUBDIR}/{_PLUGIN_NAME}"
def _plugin_version() -> str:
"""Resolve the plugin version from installed package metadata.
Reads the ``aeat`` distribution version through :mod:`importlib.metadata`,
falling back to the in-package ``__version__`` when the distribution is not
installed (an editable checkout run straight from the source tree).
"""
try:
return _metadata.version(_PLUGIN_NAME)
except _metadata.PackageNotFoundError:
return __version__
[docs]
class PluginManifest(BaseModel):
"""Result of materialising a Claude plugin from the shipped harness source.
``skills_written`` / ``agents_written`` count the ``skills/<name>/SKILL.md``
and ``agents/<persona>.md`` documents written at the plugin root;
``persona_default`` is the ``userConfig`` persona default baked into the
manifest (empty string = the full tool surface).
"""
model_config = _STRICT_FROZEN
output_path: str = Field(min_length=1)
plugin_name: str = Field(min_length=1)
version: str = Field(min_length=1)
skills_written: int = Field(ge=0)
agents_written: int = Field(ge=0)
persona_default: str = ""
[docs]
class MarketplaceManifest(BaseModel):
"""Result of materialising the marketplace-served tree from the harness source.
``plugin_source`` is the relative ``plugins[].source`` the marketplace
manifest points at (``./plugins/aeat``); ``plugin`` is the nested
:class:`PluginManifest` for the plugin materialised under that source, so the
marketplace and the plugin it serves are one emission and cannot drift.
"""
model_config = _STRICT_FROZEN
output_path: str = Field(min_length=1)
marketplace_name: str = Field(min_length=1)
plugin_source: str = Field(min_length=1)
plugin: PluginManifest
def _write_json(dest_dir: Path, name: str, document: object) -> None:
dest_dir.mkdir(parents=True, exist_ok=True)
(dest_dir / name).write_text(json.dumps(document, indent=2) + "\n", encoding=_UTF_8)
_PERSONA_CONFIG_KEY = "persona"
_PERSONA_CONFIG_TITLE = "Persona"
_PERSONA_CONFIG_DESCRIPTION = (
"The harness persona scoping the tool surface; leave blank for the full "
"surface. The aeat-mcp server validates the value and refuses an unknown "
"persona."
)
_SURFACE_CONFIG_KEY = "surface"
_SURFACE_CONFIG_TITLE = "Tool surface"
_SURFACE_CONFIG_DEFAULT = "core"
_SURFACE_CONFIG_DESCRIPTION = (
"Which tools the server advertises up front: 'core' (default) advertises the "
"orientation slice plus search/execute; 'full' advertises every verb. Either "
"way the whole verb universe stays reachable through search and execute."
)
def _plugin_user_config(persona_default: str) -> dict[str, object]:
"""Build the ``userConfig`` block declaring the persona string option.
The plugin format offers no enum/dropdown ``userConfig`` type, so the
persona is a string option with a default; the aeat-mcp server stays the
refusal surface for an unknown persona.
"""
return {
_PERSONA_CONFIG_KEY: {
"type": "string",
"title": _PERSONA_CONFIG_TITLE,
"description": _PERSONA_CONFIG_DESCRIPTION,
"default": persona_default,
"required": False,
},
_SURFACE_CONFIG_KEY: {
"type": "string",
"title": _SURFACE_CONFIG_TITLE,
"description": _SURFACE_CONFIG_DESCRIPTION,
"default": _SURFACE_CONFIG_DEFAULT,
"required": False,
},
}
def _plugin_manifest_document(version: str, persona_default: str) -> dict[str, object]:
"""Build the ``.claude-plugin/plugin.json`` manifest document.
``name`` is the sole validator-required field; the remaining fields are the
publication metadata a first-class external-service plugin declares.
``defaultEnabled`` is ``false`` per the external-service recommendation so
the plugin never auto-activates its MCP server on install. ``userConfig``
declares the persona option prompted on enable.
"""
return {
"$schema": _PLUGIN_SCHEMA,
"name": _PLUGIN_NAME,
"displayName": _PLUGIN_DISPLAY_NAME,
"description": _PLUGIN_DESCRIPTION,
"version": version,
"author": {"name": _PLUGIN_AUTHOR_NAME},
"license": _PLUGIN_LICENSE,
"keywords": list(_PLUGIN_KEYWORDS),
"defaultEnabled": False,
"userConfig": _plugin_user_config(persona_default),
}
def _emit_plugin_skills(output_dir: Path) -> int:
"""Copy every shipped skill subtree under the plugin's top-level ``skills/``.
The plugin skill layout (``skills/<name>/SKILL.md`` plus each skill's
``reference/`` progressive-disclosure material) is the same authored source
the workspace layout emits, moved from ``.claude/skills`` to the plugin root
so a Claude plugin loads it natively.
"""
skills = 0
skills_root = harness_root().joinpath(_SKILLS_SUBDIR)
if skills_root.is_dir():
for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name):
if skill_dir.is_dir() and skill_dir.joinpath(_SKILL_ENTRYPOINT).is_file():
_copy_skill(skill_dir, output_dir / _SKILLS_SUBDIR / skill_dir.name)
skills += 1
return skills
_MARKDOWN_SUFFIX = ".md"
_TOOL_SCOPE_HEADING = "## Tool scope"
# Claude built-in tools that mutate the local workspace filesystem. A persona
# whose declared tool scope is read-only (orchestration only) does not carry
# them; every other persona inherits the full tool set and relies on the
# aeat-mcp server's own persona-scope gate as the refusal surface (per the ADR:
# server-side validation stays the refusal surface).
_WORKSPACE_MUTATION_TOOLS = ("Edit", "Write", "NotebookEdit")
def _persona_slug(file_name: str) -> str:
"""Return the persona slug (the ``agents/<slug>.md`` name) for a source file."""
if file_name.endswith(_MARKDOWN_SUFFIX):
return file_name[: -len(_MARKDOWN_SUFFIX)]
return file_name
def _persona_description(text: str) -> str:
"""Return the persona's first body paragraph as a one-line description.
Claude reads an agent's ``description`` frontmatter as the delegation signal,
so the first prose paragraph (the persona's role summary, following its H1
title) is collapsed to a single line.
"""
para: list[str] = []
seen_title = False
for line in text.splitlines():
stripped = line.strip()
if not seen_title:
if stripped.startswith("#"):
seen_title = True
continue
if not stripped:
if para:
break
continue
para.append(stripped)
return " ".join(para)
def _persona_is_read_only(text: str) -> bool:
"""Return whether the persona's declared ``Tool scope`` is read-only.
The single clean signal a persona's prose exposes is its ``Tool scope``
section opening with ``Read-only`` (the coordinator's orchestration-only
role). Those personas map cleanly onto a Claude ``disallowedTools`` denylist
of the workspace-mutation built-ins; a persona whose scope declares local
state mutation does not, and inherits the full tool set.
"""
body: list[str] = []
collecting = False
for line in text.splitlines():
if line.strip() == _TOOL_SCOPE_HEADING:
collecting = True
continue
if collecting:
if line.startswith("## "):
break
body.append(line)
return "\n".join(body).strip().lower().startswith("read-only")
def _persona_agent_document(slug: str, text: str) -> str:
"""Render a persona as a Claude-native ``agents/<slug>.md`` document.
The Claude agent frontmatter carries ``name`` and ``description`` and, for a
read-only persona, a ``disallowedTools`` denylist. It NEVER carries the
vaultspec-style ``mode:`` field, which is not a Claude field. The persona's
original prose follows the frontmatter unchanged as the agent's system prompt.
"""
front = ["---", f"name: {slug}", f"description: {json.dumps(_persona_description(text))}"]
if _persona_is_read_only(text):
front.append("disallowedTools:")
front.extend(f" - {tool}" for tool in _WORKSPACE_MUTATION_TOOLS)
front.append("---")
return "\n".join(front) + "\n\n" + text
def _emit_plugin_agents(output_dir: Path) -> int:
"""Write each persona as a Claude-native ``agents/<slug>.md`` document."""
agents_dir = output_dir / _AGENTS_SUBDIR
count = 0
for persona in iter_personas():
slug = _persona_slug(persona.name)
document = _persona_agent_document(slug, persona.read_text(encoding=_UTF_8))
_write(agents_dir, persona.name, document)
count += 1
return count
def _mcp_config_document(version: str) -> dict[str, object]:
"""Build the plugin's ``.mcp.json`` declaring the stdio ``aeat-mcp`` server."""
return {
"mcpServers": {
_MCP_SERVER_NAME: {
"command": _MCP_LAUNCHER,
"args": ["--from", f"{_PYPI_DISTRIBUTION}[agent]=={version}", _MCP_CONSOLE_SCRIPT],
"env": {
_MCP_PERSONA_ENV: _MCP_PERSONA_INTERPOLATION,
_MCP_SURFACE_ENV: _MCP_SURFACE_INTERPOLATION,
},
},
},
}
[docs]
def materialise_plugin(
output_dir: Path,
*,
version: str | None = None,
persona_default: str = "",
) -> PluginManifest:
"""Write the shipped harness under ``output_dir`` as a Claude plugin.
Emits ``.claude-plugin/plugin.json`` carrying the plugin manifest (including
the ``userConfig`` persona option), the top-level ``skills/<name>/SKILL.md``
tree (plus each skill's ``reference/`` material), the ``agents/<persona>.md``
tree with Claude-native frontmatter, and the ``.mcp.json`` stdio server
declaration, all from the single authored harness source. The ``version`` is
resolved from installed package metadata when not supplied;
``persona_default`` seeds the ``userConfig`` persona default.
Returns:
:class:`PluginManifest` describing the plugin written.
"""
resolved_version = version or _plugin_version()
_write_json(
output_dir / _PLUGIN_DIR,
_PLUGIN_MANIFEST,
_plugin_manifest_document(resolved_version, persona_default),
)
skills = _emit_plugin_skills(output_dir)
agents = _emit_plugin_agents(output_dir)
_write_json(output_dir, _MCP_CONFIG, _mcp_config_document(resolved_version))
return PluginManifest(
output_path=str(output_dir),
plugin_name=_PLUGIN_NAME,
version=resolved_version,
skills_written=skills,
agents_written=agents,
persona_default=persona_default,
)
def _marketplace_manifest_document() -> dict[str, object]:
"""Build the ``.claude-plugin/marketplace.json`` manifest document.
``name``, ``owner`` (object), and ``plugins[]`` are the validator-required
fields; ``description`` is required additionally under ``--strict`` (its
absence is a strict-failing warning). The single ``plugins[]`` entry sources
the plugin from the relative ``./plugins/aeat`` subtree this generator
materialises alongside the manifest.
"""
return {
"name": _MARKETPLACE_NAME,
"description": _MARKETPLACE_DESCRIPTION,
"owner": {"name": _MARKETPLACE_OWNER_NAME},
"plugins": [
{"name": _PLUGIN_NAME, "source": _MARKETPLACE_PLUGIN_SOURCE},
],
}
[docs]
def materialise_marketplace(
output_dir: Path,
*,
version: str | None = None,
persona_default: str = "",
) -> MarketplaceManifest:
"""Write the marketplace-served tree under ``output_dir`` from the harness source.
Emits ``.claude-plugin/marketplace.json`` listing the aeat plugin and, under
the relative ``plugins/aeat`` source it points at, the full plugin tree via
:func:`materialise_plugin`. Because both come from one call, the marketplace
manifest and the plugin it serves cannot drift. ``version`` and
``persona_default`` pass straight through to the plugin emission.
Returns:
:class:`MarketplaceManifest` describing the marketplace tree written.
"""
_write_json(output_dir / _PLUGIN_DIR, _MARKETPLACE_MANIFEST, _marketplace_manifest_document())
plugin = materialise_plugin(
output_dir / _MARKETPLACE_PLUGINS_SUBDIR / _PLUGIN_NAME,
version=version,
persona_default=persona_default,
)
return MarketplaceManifest(
output_path=str(output_dir),
marketplace_name=_MARKETPLACE_NAME,
plugin_source=_MARKETPLACE_PLUGIN_SOURCE,
plugin=plugin,
)
[docs]
class WorkspaceManifest(BaseModel):
"""Result of materialising a Claude-native operator workspace.
``rules_written`` / ``personas_written`` / ``skills_written`` count the
``.claude/rules``, ``.claude/agents``, and ``.claude/skills`` documents
written; the aggregating ``CLAUDE.md`` is derived from the rules and is not
separately counted.
"""
model_config = _STRICT_FROZEN
output_path: str = Field(min_length=1)
rules_written: int = Field(ge=0)
personas_written: int = Field(ge=0)
skills_written: int = Field(ge=0)
def _write(dest_dir: Path, name: str, text: str) -> None:
dest_dir.mkdir(parents=True, exist_ok=True)
(dest_dir / name).write_text(text, encoding=_UTF_8)
def _claude_memory(rule_names: Sequence[str]) -> str:
"""Render the root ``CLAUDE.md`` that imports every operator rule.
Claude Code loads ``CLAUDE.md`` from the project root at session start and
resolves ``@path`` lines as imports, so importing each ``.claude/rules``
document makes the operator operating rules the always-on operating contract.
"""
imports = "\n".join(f"@{_CLAUDE_DIR}/{_RULES_SUBDIR}/{name}" for name in rule_names)
return (
"# aeat operator workspace\n\n"
"Claude-native materialisation of the aeat operator harness. The aeat CLI is a\n"
"deterministic Spanish-tax tool universe; this harness is how to operate it\n"
"safely. The operating rules imported below are your always-on operating\n"
"contract. Tax-advisor personas are Claude subagents under\n"
f"`{_CLAUDE_DIR}/{_AGENTS_SUBDIR}/`, and the workflow skills are under\n"
f"`{_CLAUDE_DIR}/{_SKILLS_SUBDIR}/`.\n\n"
"## Operating rules\n\n"
f"{imports}\n"
)
[docs]
def materialise_workspace(output_dir: Path) -> WorkspaceManifest:
"""Write the shipped harness under ``output_dir`` in the Claude-native layout.
Emits ``.claude/skills/<name>/SKILL.md`` (plus each skill's ``reference/``
material), ``.claude/agents/<persona>.md``, ``.claude/rules/<rule>.md``, and a
root ``CLAUDE.md`` importing every rule.
Returns:
:class:`WorkspaceManifest` describing the files written.
"""
claude_dir = output_dir / _CLAUDE_DIR
rules_dir = claude_dir / _RULES_SUBDIR
rule_names: list[str] = []
for rule in iter_operator_rules():
_write(rules_dir, rule.name, rule.read_text(encoding=_UTF_8))
rule_names.append(rule.name)
agents_dir = claude_dir / _AGENTS_SUBDIR
personas = 0
for persona in iter_personas():
_write(agents_dir, persona.name, persona.read_text(encoding=_UTF_8))
personas += 1
skills = 0
skills_root = harness_root().joinpath(_SKILLS_SUBDIR)
if skills_root.is_dir():
for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name):
if skill_dir.is_dir() and skill_dir.joinpath(_SKILL_ENTRYPOINT).is_file():
_copy_skill(skill_dir, claude_dir / _SKILLS_SUBDIR / skill_dir.name)
skills += 1
_write(output_dir, _CLAUDE_MEMORY_FILE, _claude_memory(rule_names))
return WorkspaceManifest(
output_path=str(output_dir),
rules_written=len(rule_names),
personas_written=personas,
skills_written=skills,
)
def _copy_skill(skill_dir: Traversable, dest_dir: Path) -> None:
"""Copy a skill's whole subtree (``SKILL.md`` plus the ``reference/`` material).
The progressive-disclosure reference a SKILL.md cites must travel with it, or a
materialised workspace loses the deeper material the operator is told to read.
"""
for child in skill_dir.iterdir():
if child.is_file():
_write(dest_dir, child.name, child.read_text(encoding=_UTF_8))
elif child.is_dir():
for leaf in child.iterdir():
if leaf.is_file():
_write(dest_dir / child.name, leaf.name, leaf.read_text(encoding=_UTF_8))