Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ frameshift automate on --sensitivity 0.7
frameshift select --task "review this authentication boundary" --format json
```

The public [`personas/`](personas/) directory is a manifest catalog. Install public personas from the registry unless you also have their complete behavioral source. Read [How It Works](docs/wiki/How-It-Works.md), [Pack Format](docs/wiki/Pack-Format.md), and [Automate Mode](docs/wiki/Automate-Mode.md) for the full model.
The public [`personas/`](personas/) directory is the pack catalog. Entries with inline `[voice]` source are complete one-file packs. A manifest without `[voice]` is metadata-only; authors must add public typed source or a public Markdown body before users can install and render it. Read [How It Works](docs/wiki/How-It-Works.md), [Pack Format](docs/wiki/Pack-Format.md), and [Automate Mode](docs/wiki/Automate-Mode.md) for the full model.

## Connect an AI agent with MCP

Expand Down Expand Up @@ -132,7 +132,7 @@ Registry publishing uses signed publisher identity and exact-snapshot review. Ac
## Repository and development

- [`crates/`](crates/) contains the Rust workspace: CLI, runtime, pack tooling, composition, conformance, memory, object storage, registry server, MCP server, watch daemon, orchestration, and selection.
- [`personas/`](personas/) contains the public persona manifest catalog and project artwork.
- [`personas/`](personas/) contains the public persona pack catalog and project artwork.
- [`docs/wiki/`](docs/wiki/) contains the maintained user, author, security, and operator documentation.

Source builds require Rust 1.88 or newer. The full workspace also requires the PostgreSQL client library used by Diesel (`libpq-dev` on Debian or Ubuntu, `libpq` on macOS).
Expand Down
10 changes: 8 additions & 2 deletions crates/frameshift-client/src/compose_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub(crate) struct CacheResolver<'a> {
by_name: BTreeMap<&'a str, &'a str>,
}

/// Builds cache-backed source resolvers for one project lockfile.
impl<'a> CacheResolver<'a> {
/// Builds a resolver from every persona currently locked for the project.
/// Later entries win on duplicate names (the lockfile itself is kept
Expand All @@ -45,6 +46,7 @@ impl<'a> CacheResolver<'a> {
}
}

/// Resolves composition specs to split or inline typed source in the cache.
impl SourceResolver for CacheResolver<'_> {
/// Resolves `spec` to a `PersonaSource` loaded from the cache entry for
/// the name portion of `spec` (the part before an optional `@version`).
Expand All @@ -64,7 +66,11 @@ impl SourceResolver for CacheResolver<'_> {
reason: "base/mixin persona is not installed in this project".to_string(),
})?;

let source = PersonaSource::load_from_dir(&self.cache_dir.join(hash))?;
Ok(source)
PersonaSource::load_from_dir_or_pack(&self.cache_dir.join(hash))?.ok_or_else(|| {
ComposeError::Unresolved {
spec: spec.to_string(),
reason: "base/mixin pack has no typed source".to_string(),
}
})
}
}
188 changes: 102 additions & 86 deletions crates/frameshift-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1199,29 +1199,27 @@ impl Client {
})
}

/// Renders a single persona's output into `rendered_root`, composing with
/// its declared `extends`/`mixin` bases when the pack has typed source.
/// Renders a single persona's output into `rendered_root`, using typed
/// source whenever present and composing declared `extends`/`mixin` bases.
///
/// Reads `pack.toml` from `cache_path` to decide which of three paths to
/// take:
/// - No `extends`/`mixin` declared: unchanged behavior, delegates to
/// [`materialize_rendered_outputs`] (markdown render source).
/// - `extends`/`mixin` declared AND `persona.toml` present: composes the
/// root with its resolved bases via `frameshift_compose::Composer`,
/// renders the composed result for every target, and applies the same
/// infra overlay as the non-composition path. Composition failures
/// (missing base, L1 override) propagate as `ClientError::Compose`.
/// - `extends`/`mixin` declared but no `persona.toml`: warns and falls
/// back to the markdown-only render path, since there is no typed
/// source for the composer to operate on.
/// Reads `pack.toml` from `cache_path` to decide which render path to take:
/// - Split `persona.toml` source or inline `pack.toml` source without
/// composition renders directly for every target.
/// - `extends`/`mixin` declared with either typed-source layout composes the
/// root with its resolved bases before rendering every target.
/// Composition failures propagate as `ClientError::Compose`.
/// - No typed source and no composition: delegates to
/// [`materialize_rendered_outputs`] using a Markdown render source.
/// - `extends`/`mixin` declared without typed source warns and falls back to
/// the Markdown render path because the composer has no structured input.
///
/// Independently of which of the three paths above is taken: if the pack
/// Independently of which path above is taken: if the pack
/// at `cache_path` ships a `pack.template.toml` manifest, every render
/// target's markdown is additionally passed through `{{token}}`
/// substitution (see [`load_template_context`] / [`substitute_tokens`])
/// before being written. The vault is opened at most once per call
/// (not once per render target). Packs that ship no such manifest render
/// byte-identically to how they did before this feature existed.
/// (not once per render target). Packs without that manifest do not open
/// the vault or run template substitution.
fn materialize_persona_rendered_outputs(
&self,
cache_dir: &Path,
Expand All @@ -1244,91 +1242,70 @@ impl Client {
})?;

let has_composition = manifest.extends.is_some() || !manifest.mixin.is_empty();
let has_typed_source = cache_path.join("persona.toml").is_file();
let typed_source = frameshift_source::PersonaSource::load_from_dir_or_pack(cache_path)
.map_err(frameshift_compose::ComposeError::from)?;

// Loaded once regardless of which render branch runs below, so a
// templated pack opens its vault a single time per materialize call
// rather than once per render target.
let template_ctx =
load_template_context(cache_path, vault_path, self.vault.as_ref(), persona_name)?;

if has_composition && has_typed_source {
// Fail closed on unsupported multi-level composition. The composer
// invoked just below resolves exactly one level: this pack's own
// `extends`/`mixin` against their cached bases. It does not recurse
// into a base's *own* declared `extends`/`mixin`, so if a resolved
// base itself declares composition, the grandparent's rules would
// be silently dropped rather than composed in -- most dangerous
// when the dropped layer carries inherited L1 safety rules. Detect
// that case up front and hard-error instead of attempting full
// recursive multi-level composition (out of scope; see
// `reject_unsupported_multi_level_base`).
if let Some(extends_spec) = manifest.extends.as_deref() {
reject_unsupported_multi_level_base(
cache_dir,
lockfile,
persona_name,
extends_spec,
)?;
}
for mixin_spec in &manifest.mixin {
reject_unsupported_multi_level_base(cache_dir, lockfile, persona_name, mixin_spec)?;
}

let root = frameshift_source::PersonaSource::load_from_dir(cache_path)
.map_err(frameshift_compose::ComposeError::from)?;
let resolver = compose_support::CacheResolver::new(cache_dir, lockfile);
let composed = frameshift_compose::Composer::new(resolver).compose(
root,
manifest.extends.clone(),
&manifest.mixin,
)?;
if let Some(root) = typed_source {
let source = if has_composition {
// Fail closed on unsupported multi-level composition. The
// composer resolves exactly one level and would otherwise drop
// a grandparent's inherited rules.
if let Some(extends_spec) = manifest.extends.as_deref() {
reject_unsupported_multi_level_base(
cache_dir,
lockfile,
persona_name,
extends_spec,
)?;
}
for mixin_spec in &manifest.mixin {
reject_unsupported_multi_level_base(
cache_dir,
lockfile,
persona_name,
mixin_spec,
)?;
}

for collision in &composed.rule_collisions {
warn!(persona = persona_name, id = %collision.id, layers = ?collision.layers, "rule id collision during composition");
}
for collision in &composed.skill_collisions {
warn!(persona = persona_name, id = %collision.id, layers = ?collision.layers, "skill id collision during composition");
}
let resolver = compose_support::CacheResolver::new(cache_dir, lockfile);
let composed = frameshift_compose::Composer::new(resolver).compose(
root,
manifest.extends.clone(),
&manifest.mixin,
)?;

let src = composed.into_source();
for (target_dir, filename, target) in [
(
"claude",
"CLAUDE.md",
frameshift_source::RenderTarget::Claude,
),
("codex", "AGENTS.md", frameshift_source::RenderTarget::Codex),
(
"gemini",
"GEMINI.md",
frameshift_source::RenderTarget::Gemini,
),
(
"generic",
"AGENTS.md",
frameshift_source::RenderTarget::Generic,
),
] {
let markdown = frameshift_source::render_to_markdown(&src, target);
let composed_content =
compose_rendered_content(persona_name, &markdown, self.config_root.as_deref());
let context =
format!("rendered markdown for persona {persona_name:?} (target {target_dir})");
let final_content =
substitute_tokens(&composed_content, &context, template_ctx.as_ref())?;
let dir = rendered_root.join(target_dir);
ensure_dir(&dir)?;
write_file(&dir.join(filename), final_content.as_bytes())?;
}
for collision in &composed.rule_collisions {
warn!(persona = persona_name, id = %collision.id, layers = ?collision.layers, "rule id collision during composition");
}
for collision in &composed.skill_collisions {
warn!(persona = persona_name, id = %collision.id, layers = ?collision.layers, "skill id collision during composition");
}

composed.into_source()
} else {
root
};

materialize_typed_source_outputs(
&source,
rendered_root,
persona_name,
self.config_root.as_deref(),
template_ctx.as_ref(),
)?;
return Ok(());
}

if has_composition {
warn!(
persona = persona_name,
"pack declares extends/mixin but has no persona.toml; rendering markdown body without composition"
"pack declares extends/mixin but has no typed source; rendering markdown body without composition"
);
}

Expand All @@ -1342,6 +1319,45 @@ impl Client {
}
}

/// Render typed persona source into each supported agent target.
fn materialize_typed_source_outputs(
source: &frameshift_source::PersonaSource,
rendered_root: &Path,
persona_name: &str,
config_root: Option<&Path>,
template_ctx: Option<&(frameshift_template::TemplateManifest, VaultData)>,
) -> Result<(), ClientError> {
for (target_dir, filename, target) in [
(
"claude",
"CLAUDE.md",
frameshift_source::RenderTarget::Claude,
),
("codex", "AGENTS.md", frameshift_source::RenderTarget::Codex),
(
"gemini",
"GEMINI.md",
frameshift_source::RenderTarget::Gemini,
),
(
"generic",
"AGENTS.md",
frameshift_source::RenderTarget::Generic,
),
] {
let markdown = frameshift_source::render_to_markdown(source, target);
let composed = compose_rendered_content(persona_name, &markdown, config_root);
let context =
format!("rendered markdown for persona {persona_name:?} (target {target_dir})");
let final_content = substitute_tokens(&composed, &context, template_ctx)?;
let dir = rendered_root.join(target_dir);
ensure_dir(&dir)?;
write_file(&dir.join(filename), final_content.as_bytes())?;
}

Ok(())
}

/// Fail closed if the persona `spec` (an `extends` or `mixin` entry, in
/// `<name>` or `<name>@<version>` form) resolves to an installed persona that
/// itself declares its own `extends`/`mixin`.
Expand Down
Loading
Loading