Files
reliquary/docs/workpad/research/2026-09-17-project-md-override-mechanics.md
T
g4borg bf0274788c 📝 Add research: project.md override mechanics
Fable-authored research on how to replace the hard-bound @ directive
in Igor and Athanor with a load-time shell injection that falls back
to the bundled template. Recommends .claude/spire/project.md as the
override location and a spire:init skill modelled on tome:init.
2026-09-17 14:01:05 +02:00

7.6 KiB

project.md override mechanics — how Spire can get a per-project config

Date: 2026-09-17 Scope: plugins/spire/skills/igor/, plugins/spire/agents/athanor.md, plugins/tome/skills/init/, plugins/tome/bin/knowledge.py Goal: docs/workpad/goals/igor-project-adoption.md

1. What actually happens today

plugins/spire/skills/igor/SKILL.md:32 contains @references/project.md; plugins/spire/agents/athanor.md:25 contains @skills/igor/references/project.md. The installed copy resolves to ~/.claude/plugins/cache/reliquary/spire/0.1.0/skills/igor/references/project.md — a comment-only template (references/project.md:9-21). Igor is preloaded into the igor agent via skills: [spire:igor, spire:workpad] (agents/igor.md:11-13), so the template is injected at agent startup with no chance to swap it.

2. How @ resolution works — and what it cannot do

The official documentation only defines @path imports for CLAUDE.md memory files: "Relative paths resolve relative to the file containing the import, not the working directory," maximum depth four, and imports outside the working directory trigger an approval dialog. The skills and sub-agents pages document no @ expansion at all. That the Igor skill's @references/mindset.md works in practice means expansion is applied to skill/agent bodies, but it is undocumented behaviour — risky to build a resolution chain on.

Two hard limits:

  • @ is a static path. There is no "first of these that exists" form, no fallback, and a missing target is not a documented success case.
  • A relative @ from a plugin file always anchors in the plugin cache. Reaching the project would need an absolute path, which cannot be known at authoring time.

So @ cannot deliver criterion 1 of the goal ("resolves from a project-local path first"). It must be replaced.

3. The mechanisms that can do it

Claude Code documents three levers that are all substituted in skill markdown at load time:

Lever Documented behaviour
${CLAUDE_PROJECT_DIR} "The project root directory ... independent of where the skill is installed." Substituted in skill body and allowed-tools.
${CLAUDE_SKILL_DIR} / ${CLAUDE_PLUGIN_ROOT} Skill subdirectory / plugin install directory (plugin skills only).
!`command` "runs shell commands before the skill content is sent to Claude. The command output replaces the placeholder." Never prompts, but a failing command "aborts the entire skill invocation."

These compose into a true load-time resolution chain that reproduces what @ does today, with a fallback:

!`cat "${CLAUDE_PROJECT_DIR}/.claude/spire/project.md" 2>/dev/null || cat "${CLAUDE_SKILL_DIR}/references/project.md"`

Caveats to design around:

  • Substitution is documented for skills, not agent bodies. athanor.md:25 should therefore stop importing directly and instead preload a skill via its skills: frontmatter (it already lists four, athanor.md:13-18).
  • The || fallback is load-bearing: the doc is explicit that any non-zero exit kills the whole skill. Use 2>/dev/null and a final || true-safe form.
  • disableSkillShellExecution: true in settings turns injection off; the skill text should then degrade to an explicit instruction (see section 4).
  • The ! must sit at line start or after whitespace; declare allowed-tools: Bash(cat:*) so the command is pre-approved in any project.

4. @/injection (load time) vs. instructed read (runtime)

Load-time injection Runtime "Read X if it exists, else Y"
Reliability Deterministic; content is in context before the first turn Depends on the model following the instruction; Igor's own rule set already leans on this ("resolve the workpad location via the spire:workpad skill", SKILL.md:38)
Cost Tokens spent every session, even if unused One tool call, only when acted on
Failure mode Shell failure aborts the skill Missing file is a harmless tool error
Agent bodies Not documented; needs the skills: indirection Works anywhere
Compaction Preloaded skill content is re-injected on fork only Re-readable at any time

Recommendation: do both. Inject at load time in a small dedicated skill, and keep a one-line instruction ("if the injected block is the blank template, look for .claude/spire/project.md before orienting") as the belt to the braces.

5. Where the override should live

Candidate Pros Cons
.claude/spire/project.md Alongside .claude/agents/, .claude/skills/, .claude/rules/ which Spire already consults (SKILL.md:63); committed with the repo; ${CLAUDE_PROJECT_DIR}-addressable New directory; the .claude/ tree is Claude-Code-specific
<workpad>/spire-project.md Workpad is the user's document home Workpad location is itself something project.md is meant to declare — circular; not resolvable from a shell one-liner
Root CLAUDE.md section Already loaded every session; no init needed Bloats a file the docs say to keep under 200 lines; not machine-swappable; Igor cannot tell "configured" from "not"
.spire/project.md Tool-agnostic Adds a top-level dotdir for one file; unfamiliar next to .claude/
.claude/rules/spire.md Auto-loaded, no injection needed Loads for every session including non-Igor ones; paths: scoping does not fit

.claude/spire/project.md is the strongest: one fixed path, resolvable by shell at load time, mirroring tome's proposed .claude/skills/<slug>-tome/ (tome/skills/init/SKILL.md:36-37). A gitignored project.local.md sibling could follow later.

6. What Tome already does that Spire can copy

  • Human-triggered init: disable-model-invocation: true, explicit allowed-tools, phased structure (tome/skills/init/SKILL.md:8-9, 20-38).
  • Detection before writing: Step 2.1 reads Cargo.toml/package.json/pyproject.toml, Justfile/Makefile, **/CLAUDE.md, ls root (init/SKILL.md:39-52) — the exact auto-fill list in the goal's criterion 2.
  • Non-clobbering scaffold: write_generated_file() returns False when the existing file is authored/stub or byte-identical (knowledge.py:467-489); cmd_bootstrap() reports created vs. skipped (knowledge.py:1144-1215). A spire:init should mark its output status: generated and only overwrite while it stays generated, so a hand-edited project.md survives re-init.
  • Reusable helpers: detect_toolchains/parse_justfile (knowledge.py:605-678) can populate verification commands.

7. Backward compatibility

The fallback branch of the injection is the bundled template, so a project without .claude/spire/project.md sees exactly what it sees today. The template's HTML-comment placeholders (references/project.md:9-21) remain the "unconfigured" signal; adding "No project override found; run /spire:init" turns the dead-end into a prompt. spire:knowledge (skills/knowledge/SKILL.md:15) already names project.md as the declaration point, so nothing else changes.

8. Proposed shape

  1. New skill plugins/spire/skills/project/SKILL.md holding the injection line plus the runtime fallback sentence; allowed-tools: Bash(cat:*).
  2. igor/SKILL.md:32 and athanor.md:25 drop the @ import; igor.md and athanor.md add spire:project to skills:.
  3. New spire:init skill (human-only) modelled on tome's Phase 2.1: detect, fill, write .claude/spire/project.md with status: generated front matter, optionally write .claude/skills/assistant/SKILL.md as the /assistant alias (criterion 3), commit.
  4. Template gains a one-line "run /spire:init" hint.