diff --git a/docs/workpad/research/2026-09-17-project-md-override-mechanics.md b/docs/workpad/research/2026-09-17-project-md-override-mechanics.md new file mode 100644 index 0000000..37008b1 --- /dev/null +++ b/docs/workpad/research/2026-09-17-project-md-override-mechanics.md @@ -0,0 +1,85 @@ +# 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: + +```markdown +!`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 | +| `/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/-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.