Skip to content

Per-persona claudeMd / agentsMd sidecar markdown files #44

@willwashburn

Description

@willwashburn

Summary

Allow each persona to attach a hand-authored CLAUDE.md / AGENTS.md to itself — job-scoped context that travels with the persona, materialized into the sandbox mount at session time so the harness reads it natively.

This fits the AgentWorkforce value prop: job-scoped + team-shareable + anti-bloat. Users get the right context only when that persona runs, instead of stuffing everything into a global CLAUDE.md.

Design

Schema

Add to PersonaSpec (top-level, sibling of tiers) and PersonaRuntime (per-tier):

claudeMd?: string;                          // relative POSIX path to .md file
claudeMdMode?: "overwrite" | "extend";      // default: "overwrite"
agentsMd?: string;
agentsMdMode?: "overwrite" | "extend";      // default: "overwrite"

Mirror on LocalPersonaOverride and its tier shape. Path is resolved relative to the JSON file that declared the field (matters for extends cascades).

Validation (paths): non-empty, no leading /, no .., must end in .md.
Validation (modes): enum check; reject mode set without a corresponding path.

Resolution

For a given (persona, selected tier, harness) at runtime:

  1. Pick the field by harness:
    • claudeclaudeMd
    • opencodeagentsMd
    • codex → none (warn + skip; no mount available)
  2. Tier-level wins over top-level. Mode resolves with the same cascade and is independent of the path (a tier can override the path while inheriting the mode, or vice versa).
  3. No cross-fallback between claudeMd and agentsMd. If you want one file applied everywhere, set both top-level fields to the same path.

Runtime delivery — write into the sandbox mount

In runInteractive (packages/cli/src/cli.ts:558+), after the mount is established and before harness invocation:

  • claude in sandbox: materialize <mountDir>/CLAUDE.md. CLEAN_IGNORED_PATTERNS (cli.ts:429-432) already excludes CLAUDE.md from sync — no real-repo pollution.
  • opencode in sandbox: materialize <mountDir>/AGENTS.md. Action item: add AGENTS.md to CLEAN_IGNORED_PATTERNS (it is currently not excluded, so without this change it would sync back into the user's real repo).
  • codex / --install-in-repo: no mount available → log a warning and continue. Never touch the user's real cwd.

Why mount-write rather than appending to systemPrompt? Native CLAUDE.md / AGENTS.md semantics — @import, hierarchy, etc. — survive. The harness reads its own convention.

packages/harness-kit/src/harness.ts does not change.

Mode behavior

Applies to both claude and opencode mount-write paths:

  • overwrite (default): write only the persona's resolved markdown content into <mountDir>/CLAUDE.md (or AGENTS.md). The user's real-cwd file at the same name is hidden by the existing sandbox sync exclusion.
  • extend: read the user's real-cwd CLAUDE.md (or AGENTS.md) if it exists, then write <real-content>\n\n---\n\n<persona-content> into the mount file. If the real file does not exist, behave as overwrite (graceful degradation, no warning). The result still lives only in the mount; the real file is never modified.

extends / merge

In mergeOverride (packages/cli/src/local-personas.ts:666-712):

  • Top-level claudeMd / agentsMd (and the modes): override replaces base wholesale, matching description semantics.
  • Tier-level: per-tier replacement, matching existing tier merge.

Path is anchored to the directory of whichever JSON declared it. Track sourcePath per parsed override internally (set in readLayerDir, local-personas.ts:283-296); resolved PersonaSpec carries the already-absolute path so downstream code doesn't re-track sources.

Built-in catalog — inline at build time

Built-in personas in personas/*.json get content inlined at generation time so installed packages don't need to ship sibling .md files for built-ins:

  • packages/workload-router/scripts/generate-personas.mjs: read sibling .md (relative to personas/), emit claudeMdContent / agentsMdContent (string) on the generated spec, drop the path field.
  • Runtime checks *Content first, otherwise reads from the absolute path.
  • Hard-fail the generator on a missing built-in .md.
  • Watch mode (lines 67-87) extends to retrigger on .md changes.

Packaging (prpm install) — targeted asset copy

packages/cli/src/persona-install.ts currently flat-copies .json only (collectJsonFiles, line 165). Extend it:

  • Replace collectJsonFiles with collectPersonaAssets — for each persona JSON, read its claudeMd / agentsMd (top-level + per-tier), resolve each relative to the JSON's dir, and add to a per-persona asset list. Reject .. / absolute paths. Hard-fail on missing files at packaging time (loud failure is right at the distribution boundary).
  • Extend PersonaFile (line 58) with assets: { sourcePath, basename }[].
  • In installPersonas (332-384): copy each persona's assets into <targetDir>/<id>__assets/<basename> and rewrite the JSON's claudeMd / agentsMd paths in-place to point at the new location.

Validation

  • parseOverride (local-personas.ts:304-363) and assertTiersShape (477-500): assert string + non-empty + ends in .md + no .. + not absolute. Reuse the style of assertSafeRelativePath (cli.ts:407-422).
  • After cascade resolution: stat the absolute path; missing file → push to load warnings and clear the field on the resolved spec (graceful in dev).
  • Generator: hard-fail on missing built-in .md.

Files affected

  • packages/workload-router/src/index.ts — extend PersonaRuntime (lines 59-64) and PersonaSpec (140-175) with claudeMd?, claudeMdMode?, agentsMd?, agentsMdMode?, plus claudeMdContent? / agentsMdContent? for inlined built-ins.
  • packages/workload-router/scripts/generate-personas.mjs — read sibling .md, emit *Content, watch .md changes, hard-fail on missing.
  • packages/cli/src/local-personas.ts — extend LocalPersonaOverride (30-57); validate in parseOverride (304-363) and assertTiersShape (477-500); track per-override source path in readLayerDir (267-298); resolve absolute paths and stat in mergeOverride (666-712) and standaloneSpecFromOverride (566-603); surface missing-file warnings in loadLocalPersonas (734-768).
  • packages/cli/src/cli.ts — add AGENTS.md to CLEAN_IGNORED_PATTERNS (429-432); carry resolved sidecar info onto PersonaSelection in buildSelection (236-253); in runInteractive (558+), pick field by harness, materialize content into the mount dir before harness invoke (with mode handling); warn-and-skip for codex / --install-in-repo.
  • packages/cli/src/persona-install.ts — extend PersonaFile (58); replace collectJsonFiles (165) with collectPersonaAssets; update collectPersonas (212) and installPersonas (332) to copy referenced sidecars and rewrite JSON paths.

packages/harness-kit/src/harness.tsno change.

Test plan

  • Loader (packages/cli/src/local-personas.test.ts)
    • Parse + validate top-level only, tier-only, both, malformed (.., absolute, non-.md)
    • Mode validation: reject mode set without path; enum-check
    • Cascade: override sets claudeMd, base does not, and vice-versa; resolved spec carries absolute path anchored to the right layer's dir
    • Mode independence: tier overrides path, inherits top-level mode (and vice versa)
    • Missing file produces warning, not throw
  • Packaging (packages/cli/src/persona-install.test.ts)
    • npm pack → install copies referenced .md files into <id>__assets/, rewrites JSON paths, leaves unreferenced .md files behind
    • Rejects .. and absolute paths at pack/install time
    • Hard-fail on missing referenced .md
  • CLI / runtime (packages/cli/src/cli.test.ts)
    • Resolution: claude → claudeMd; opencode → agentsMd; codex → warn + skip; tier-level wins over top-level; no cross-fallback
    • overwrite mode: <mountDir>/CLAUDE.md contains exactly the persona content (claude) / <mountDir>/AGENTS.md (opencode)
    • extend mode: real cwd has CLAUDE.md → mount file = <real>\n\n---\n\n<persona>; real cwd has no file → mount file = persona content only (no warning)
    • Real cwd is never mutated in either mode
    • --install-in-repo: warn + no file written into real cwd
  • Generator (snapshot)
    • Built-in persona referencing a sibling .md produces generated TS with *Content populated and the path field gone
    • Missing .md hard-fails the generator
  • Manual smoke
    • Author a local persona with claudeMd (overwrite), run agentworkforce agent <persona>@best (claude), confirm the agent acknowledges the contents on first turn
    • Same persona with claudeMdMode: "extend" in a repo that has its own CLAUDE.md — confirm both sets of context are visible to the agent
    • Repeat for opencode tier (agentsMd)

Out of scope

  • Native CLAUDE.md @import / hierarchy semantics for codex (codex has no mount and no file convention support in this repo).
  • --install-in-repo mode — never write into the user's real repo; warn + skip.
  • Cross-fallback between claudeMd and agentsMd.
  • Multiple sidecars per (persona, tier).
  • Inline content in JSON (only path form is accepted from authors; build-time inlining is internal to the catalog generator).
  • Hot-reload of sidecar content during a live session.

Related

  • Recent persona work: feat(cli): add persona pack install command #40 (installable persona sources), persona create mode (4b21bf9), preserve standalone persona inputs (fedd22e).
  • Distinction from skills (which are declared by URL and materialized at session time): sidecar markdown files are author-shipped along with the persona JSON and copied at install time.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions