Files
holocron/docs/spec/architecture.md
Defame1297 dee56c506a feat(kyberforge): refresh the apm install at SessionStart, not at push
Why
---
ADR-0018 left deployed skills tracking the remote default branch with nothing
watching for drift. The mechanism that was supposed to cover this,
scripts/git-hooks/post-push, could never have worked: git has no client-side
post-push hook. install.sh copied it into .git/hooks/ so it looked installed,
and it had never once fired. Issue #78 reported it as skipping the gitea
plugin; it was skipping everything.

Refreshing on push was also the wrong shape. Your install goes stale when
someone else merges, so a push of your own is neither necessary nor sufficient
for staleness to have occurred.

Implementation notes
--------------------
kyberforge ships a SessionStart hook (startup matcher only) that runs
`apm outdated`, and when anything is behind runs `apm update --yes` and returns
reloadSkills:true so the running session picks up redeployed content. It exits
silently with no apm.lock.yaml present, which keeps it inert for hosts that
installed this plugin natively rather than through apm.

Two findings drove the wiring, both verified rather than assumed:

- apm resolves ${CLAUDE_PLUGIN_ROOT} against the installed package root, and
  `apm pack` keeps only *.json from .apm/hooks/. A .../hooks/<script> reference
  therefore points into the generated mirror where the script does not exist —
  apm reports "Hook script not found" and deploys a hook aimed at nothing. The
  reference must be .apm/-relative, and a test pins it.
- apm's executable-trust gate is OFF unless apm.yml carries an `executables:`
  block; until now every hook, bin and MCP primitive a dependency shipped would
  have deployed unprompted. Root apm.yml now enables it. The allow key is
  version-pinned by apm's design, so a kyberforge version bump silently blocks
  the hook until the key is bumped too — called out in the block and the ADR.

Also corrects ADR-0018 and AGENTS.md, which named `apm install` as the refresh
command. It is not: `apm install` deploys from apm.lock.yaml's pinned commit
and does not re-resolve refs. `apm update` does.

Impact
------
Session startup costs ~0.7s when current and ~10.4s when six packages are
behind. Auto-refresh rewrites apm.lock.yaml, so an unexplained modification to
it after opening a session is expected; the emitted notice says so.

.claude/settings.json stops being exactly {"hooks": {}} once the hook lands
there — the merged entry is apm's own output, and the rule that nothing
repo-authored goes in that file is unchanged. .claude/hooks/ and the
.claude/apm-hooks.json sidecar are gitignored install output.

The hook cannot install itself: dependencies resolve from the remote, so it
takes effect only after this merges and `apm update` runs once against the new
default branch.

scripts/git-hooks/ is now empty. install.sh's copy block is kept and
test-git-hooks-install.sh synthesizes its own fixture, so the mechanism stays
tested without requiring a dead hook to exist.

ADR: 0019
Refs: #78

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 17:46:38 +00:00

7.7 KiB

Architecture

Layered model

this repo (global defaults)
    └── scripts/install.sh → ~/.claude/         (Claude Code config + content)

project repo (local overrides)
    └── .claude/settings.json, CLAUDE.md (overrides global)

Content deployment model

scripts/install.sh is a deployer, not a composer. It sources scripts/deploy-manifest.sh and deploys three categories:

  • Files (DEPLOY_FILES): providers/claude-code/CLAUDE.md → ~/.claude/CLAUDE.md; providers/claude-code/settings.json → ~/.claude/settings.json; core/AGENTS.md → ~/.agents/AGENTS.md
  • Executables (DEPLOY_EXECUTABLES): providers/claude-code/statusline-command.sh → ~/.claude/statusline-command.sh (with +x)
  • Directories (DEPLOY_DIRS): core/ → ~/.claude/core/ (destination fully replaced on each deploy)

Skills are not deployed by install.sh. They are distributed as plugins and installed separately — in this repo by apm install against the dependencies.apm entries in the root apm.yml, which lands them in .claude/skills/ and .claude/agents/ (ADR-0018); elsewhere by claude plugin install <name>@holocron.

~/.claude/CLAUDE.md is a thin adapter, not a content source. It imports ~/.agents/AGENTS.md (always-on rules) and governance.md (always-on governance), then lists the content index. All always-on content lives in AGENTS.md files so other providers can import the same source without duplication.

Plugin model

Skills, agents, MCP servers, and hooks are distributed as self-contained plugin units under plugins/, installed independently — via apm install here, or claude plugin install <name>@holocron for a host consuming the marketplace natively (ADR-0018). Each plugin is an apm package: plugins/<name>/apm.yml plus a hand-authored plugins/<name>/.apm/{skills,agents,hooks,commands,instructions,extensions}/ tree (ADR-0015). There is no hand-maintained plugin.json — every manifest and every host-visible content directory is compiled from that source.

Two compilers produce the plugin roots you see in the tree:

  • apm pack compiles the manifests (ADR-0015). Per plugin: .claude-plugin/plugin.json and .github/plugin/plugin.json, both generated from plugins/<name>/apm.yml. Repo-wide, from the root apm.yml's marketplace: block: .claude-plugin/marketplace.json (apm's claude output profile) and .agents/plugins/marketplace.json (its codex profile, a differently-shaped file). Those two are the only marketplace outputs apm has profiles for — the third root manifest, .github/plugin/marketplace.json (Copilot CLI's legacy path), is a byte-identical mirror of the Claude one maintained by scripts/sync-marketplace-mirror.sh and gated by the check-marketplace-mirror-sync pre-push hook.
  • scripts/sync-plugin-content.sh compiles the content mirror (ADR-0017). It wraps apm pack --format plugin and copies the resulting bundle's flat agents/, skills/, commands/, instructions/, extensions/, and merged hooks/hooks.json back to the plugin root. Claude Code's installer convention-scans those flat paths and has no .apm/ awareness whatsoever, so the mirror exists solely to satisfy the host's discovery contract.

.apm/ is the sole hand-edited authoring source for plugin content. An edit made in the flat mirror is discarded by the next sync and is reported as drift by the check-plugin-content-sync pre-push hook. Hand-authored material that is not an .apm/ primitive — README.md, docs/, bin/, sources.md, .mcp.json, and per-plugin extras such as plugins/git/config.example.json, plugins/gitea/references/ and plugins/bin/evals/ — lives at the plugin root and is untouched by either compiler.

That immunity is positional, not by filename. Anything placed inside a mirrored directory is destroyed regardless of what it is: sync_dir runs rm -rf "$dst" before every copy, and sync_hooks_json does the same to hooks/. A hand-written README.md under plugins/<name>/hooks/ or plugins/<name>/skills/ is deleted by the next sync with no drift report, because a file with no .apm/ counterpart is simply absent from the regenerated tree. This has already cost the repo one document — plugins/kyberforge/hooks/README.md, since restored to plugins/kyberforge/docs/hooks.md. Plugin-root documentation belongs in docs/.

Governance layer

core/instructions/governance.md is the always-on governance instruction file. Unlike the on-demand instruction files in the content index, governance.md is loaded into every Claude session via @import in providers/claude-code/CLAUDE.md. This is a technical guarantee, not a behavioural instruction — @import causes Claude Code to expand and load the file at launch, before any interaction begins.

The governance layer has two phases:

  • Phase 1 (complete): instruction and documentation layer — governance.md loaded via @import; docs/ai-constitution.md and docs/wiki/HUMANS.md as human-facing reference; CONTEXT.md extended with governance domain language.
  • Phase 2 (planned): deterministic enforcement layer — pre-commit hooks, CI gates, secret scanning, licence scanning. Specified in docs/research/governance_principles/CONTROLS.md.

AGENTS.md pattern

This repo uses two AGENTS.md files as the provider-agnostic source of always-on rules (ADR-0003):

  • Repo-level AGENTS.md — instructions for agents working inside this repo (structure, key rules). Imported by repo CLAUDE.md via @AGENTS.md.
  • Global core/AGENTS.md — Communication and Behavior rules that apply across all projects. Deployed to ~/.agents/AGENTS.md; imported by ~/.claude/CLAUDE.md via @~/.agents/AGENTS.md.

Both CLAUDE.md files are thin adapters: they import from their respective AGENTS.md and add only Claude Code-specific syntax (@import, content index paths). They carry no original always-on content.

This repo also has a CLAUDE.md at its root — the Claude Code entry point for working in this repo. It imports AGENTS.md and nothing else; there is no @CONTEXT.md import. It is not import-only either: below the import sits a fenced <!-- rtk-instructions v2 --> … <!-- /rtk-instructions --> block carrying the RTK command-prefix convention, which is tool-specific content with no AGENTS.md source. This is distinct from providers/claude-code/CLAUDE.md, which is the global config deployed to ~/.claude/.

CONTEXT.md is therefore not always-loaded. AGENTS.md instructs agents to read it at session start, which is a behavioural instruction, not an @import guarantee — LESSONS.md's 2026-05-17 entry proposed adding the import and it was never applied. Treat that entry as open work rather than a record of a landed change.

Provider model

core/ is never tool-specific. providers/ is never shared. When adding a new provider, write an adapter in providers/<name>/ that translates core content into the tool's expected format and location. The core content itself does not change.

Architectural decisions

Key hard-to-reverse decisions are recorded as ADRs in docs/adr/. There is no index file — the directory holds 19 numbered ADRs whose filenames state their decision, so ls docs/adr/ is the index. Read a superseding ADR before the one it supersedes: ADR-0015 (apm as the authoring source of truth) supersedes ADR-0001 and moots ADR-0006, ADR-0017 corrects ADR-0015's host-discovery gap, and ADR-0019 supersedes one claim in ADR-0018 (that .claude/settings.json's committed content is exactly {"hooks": {}}) while keeping the rule behind it. Entry points for the structure described on this page: ADR-0002 (two-tier CLAUDE.md), ADR-0003 (AGENTS.md as the provider-agnostic entry point), ADR-0015 and ADR-0017 (the two compilers behind the plugin roots).