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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Added

- Model-level qualifier resolution for rules and agents (PROV-0005 Phase 1). Assembly now resolves the full `user/` > `provider/<model>/` > `provider/` > base precedence: a file at `rules/<provider>/<model-id>/Rule.md` overrides the base for that provider and model, and the long-documented `user/` overlay is finally wired for rules and agents too. Each provider gains a default `model` in `defaults.yaml` (an exact ID from `config/models.yaml`); `forge assemble --model <ID>` and `forge install --model <ID>` override it for providers that list that model. Qualifier directory names are validated as exact model IDs: model IDs are no longer split into segments, so a directory named `4` or `6` (from `claude-opus-4-6`) is no longer a valid qualifier, and a model-only file under `rules/<provider>/<model-id>/` is collected instead of silently dropped. An unrecognized model-qualifier subdirectory is skipped with a warning rather than dropped silently. Skill overlays, recording the model in `.manifest`/provenance sidecars, and the per-model release matrix are deferred to Phase 2. (#60)
- `forge drift --target <BASE>` verifies a module's assembled `build/` against where it was deployed, scoped to the module's own files. It mirrors `forge install --target`: each provider's `build/<provider>` is compared to `<BASE>/<provider-target>`, so files built but not yet deployed surface as local-only, deployment edits surface as frontmatter/body drift, and this module's deployed files (per the target `.manifest` plus provenance attribution) that are no longer built surface as drift. Unlike `--upstream` against a multi-module tree such as `~/.claude`, other modules' files are never reported. `--target` and `--upstream` are mutually exclusive. (#61)
- `forge validate` sanity-checks Claude Code plugin scaffolding when `.claude-plugin/plugin.json` is present: each manifest (`plugin.json`, `.claude-plugin/marketplace.json`, `hooks/hooks.json`) must be valid JSON, and every hook script referenced via `${CLAUDE_PLUGIN_ROOT}` must exist and be executable (the most common cause of hooks silently not firing). It deliberately makes no assertions about the plugin or marketplace field schema, so it does not break when Claude Code's plugin format changes. Non-plugin modules are unaffected: the check runs only when `plugin.json` exists. (#59)
- `.forge` consumer manifests accept `git:` sources pinned to a 40-hex commit SHA. `forge install` clones the remote via `gix` into a content-addressed cache at `~/.cache/forge/git/<host>/<owner>/<repo>/`, materializes the pinned tree, and feeds it through the standard assemble + deploy pipeline. HTTPS-only; `ssh://`, `git://`, `git@host:` shorthand, and userinfo URLs are rejected at parse time. Branch names, tags, and abbreviated SHAs are rejected in favor of explicit 40-char commit hashes. Cache hits are instant; the bare clone is reused across SHA pins within the same repository. (#53)
Expand Down
4 changes: 4 additions & 0 deletions defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dashboard:
providers:
claude:
target: ".claude"
model: claude-opus-4-6
aliases:
- claudecode
keep_fields:
Expand Down Expand Up @@ -51,6 +52,7 @@ providers:

gemini:
target: ".gemini"
model: gemini-2.5-pro
aliases:
- geminicli
assembly:
Expand All @@ -68,6 +70,7 @@ providers:

codex:
target: ".codex"
model: gpt-5.5
aliases:
- codexcli
assembly:
Expand All @@ -90,6 +93,7 @@ providers:

opencode:
target: ".opencode"
model: claude-opus-4-6
assembly:
- kebab-case-agents
- strip-links
Expand Down
3 changes: 3 additions & 0 deletions docs/todos/2026-07-06.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Backlog 2026-07-06

- [ ] #forge-cli/install `forge install --target ~` deploys stale artifacts when a pre-existing `build/` directory is present — it neither rebuilds nor verifies freshness against source. Repro: edit a skill source in forge-dev, run `make install` (delegates to `forge install --target ~`) with an old `build/` in place; the outdated build output lands in the target. Session workaround was moving `build/` aside before installing. Suggested fix: rebuild into a temp dir, or compare source vs `build/` mtimes/hashes and warn or fail on staleness.
2 changes: 2 additions & 0 deletions src/assemble/pipeline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ fn make_provider(assembly: Option<Vec<&str>>) -> ProviderConfig {
models: None,
effort: None,
aliases: None,
model: None,
}
}

Expand Down Expand Up @@ -161,6 +162,7 @@ fn unknown_rule_collected_as_error() {
models: None,
effort: None,
aliases: None,
model: None,
},
);

Expand Down
34 changes: 32 additions & 2 deletions src/cli/assemble/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ use crate::cli::config;
/// gemini/agents/security-architect.md (with remapped tools)
/// gemini/agents/security-architect.yaml
/// ```
pub fn execute(path: &str) -> Result<ActionResult, Error> {
/// Assemble, selecting model variants with `model_override` (the `--model`
/// flag) in place of each provider's configured default model.
pub fn execute_with_model(path: &str, model_override: Option<&str>) -> Result<ActionResult, Error> {
let module_root = Path::new(path);
if !module_root.is_dir() {
return Err(Error::new(
Expand Down Expand Up @@ -95,12 +97,15 @@ pub fn execute(path: &str) -> Result<ActionResult, Error> {

let model_tiers = provider_config.models.clone().unwrap_or_default();
let effort_tiers = provider_config.effort.clone().unwrap_or_default();
let active_model =
resolve_active_model(model_override, provider_config, &models, provider_name);

for source in &source_files {
if let Some(deployed) = assemble_source_for_provider(
source,
module_root,
provider_name,
active_model.as_deref(),
provider_config,
&provider_build_dir,
&tool_mappings,
Expand All @@ -118,11 +123,32 @@ pub fn execute(path: &str) -> Result<ActionResult, Error> {
Ok(result)
}

/// Choose the model ID whose `provider/<model>/` variants win this assembly:
/// `--model` when it is a valid model for the provider, otherwise the
/// provider's configured default. An override that names another provider's
/// model is ignored so a single `--model` flag is safe across all providers.
fn resolve_active_model(
model_override: Option<&str>,
provider_config: &commands::provider::ProviderConfig,
models: &std::collections::HashMap<String, Vec<String>>,
provider_name: &str,
) -> Option<String> {
if let Some(override_id) = model_override
&& models
.get(provider_name)
.is_some_and(|ids| ids.iter().any(|id| id == override_id))
{
return Some(override_id.to_string());
}
provider_config.model.clone()
}

#[allow(clippy::too_many_arguments)]
fn assemble_source_for_provider(
source: &sources::SourceFile,
module_root: &Path,
provider_name: &str,
active_model: Option<&str>,
provider_config: &commands::provider::ProviderConfig,
provider_build_dir: &Path,
tool_mappings: &std::collections::HashMap<String, String>,
Expand Down Expand Up @@ -159,6 +185,7 @@ fn assemble_source_for_provider(
source,
module_root,
provider_name,
active_model,
&kind_keep_fields,
model_tiers,
effort_tiers,
Expand All @@ -172,9 +199,12 @@ fn assemble_source_for_provider(
.relative_path
.strip_prefix(&format!("{}/", source.kind))
.unwrap_or(&source.relative_path);
// Qualifier-only files live one or two levels deep (rules/<provider>/file
// or rules/<provider>/<model>/file); deploy them flat under the kind, so
// strip every qualifier segment down to the basename.
let relative_within_kind = if source.qualifier.is_some() {
stripped_kind
.split_once('/')
.rsplit_once('/')
.map_or(stripped_kind, |(_, filename)| filename)
} else {
stripped_kind
Expand Down
9 changes: 8 additions & 1 deletion src/cli/assemble/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ use crate::cli::config::read_file;
/// → references stripped
/// → tool names remapped (Read → read_file for gemini)
/// ```
#[allow(clippy::too_many_arguments)]
pub fn assemble_source(
source: &SourceFile,
module_root: &Path,
provider_name: &str,
model: Option<&str>,
keep_fields: &[String],
model_tiers: &HashMap<String, Vec<String>>,
effort_tiers: &HashMap<String, String>,
Expand All @@ -38,7 +40,12 @@ pub fn assemble_source(
let source_dir = Path::new(&source.full_path).parent().unwrap_or(module_root);
let filename = extract_filename(&source.full_path);

let variant = assemble::variants::resolve(source_dir, &filename, &[provider_name.to_string()]);
// Resolution precedence (PROV-0005): user/ > provider/model/ > provider/ > base.
let mut qualifiers = vec!["user".to_string(), provider_name.to_string()];
if let Some(model_id) = model {
qualifiers.push(model_id.to_string());
}
let variant = assemble::variants::resolve(source_dir, &filename, &qualifiers);

let variant_content = match &variant {
Some(vp) => Some(read_file(vp)?),
Expand Down
111 changes: 95 additions & 16 deletions src/cli/assemble/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ fn parse_targets(content: &str) -> Option<Vec<String>> {
/// qualifier: None,
/// }
/// ```
#[derive(Debug)]
pub struct SourceFile {
/// Relative path from the module root (e.g. "rules/MyRule.md").
pub relative_path: String,
Expand Down Expand Up @@ -172,6 +173,7 @@ fn walk_content_dir(
module_root,
sources,
&base_filenames,
valid_qualifiers,
)?;
}

Expand All @@ -181,15 +183,22 @@ fn walk_content_dir(
/// Walk a qualifier subdirectory, collecting only qualifier-only files.
///
/// Files that share a name with a base file are variant overrides, handled
/// by `variants::resolve` during assembly of the base file. Only files
/// with no base counterpart are collected as qualifier-only sources.
/// by `variants::resolve` during assembly of the base file. Only files with
/// no base counterpart are collected as qualifier-only sources.
///
/// A subdirectory whose name is a valid qualifier (a model ID from
/// `config/models.yaml`) is descended into so model-only files under
/// `rules/<provider>/<model>/` are collected, tagged with the model ID as
/// their qualifier. Any other subdirectory is a validation error rather than
/// being silently dropped.
fn walk_qualifier_dir(
dir: &Path,
qualifier_name: &str,
kind: commands::provider::ContentKind,
module_root: &Path,
sources: &mut Vec<SourceFile>,
base_filenames: &HashSet<String>,
valid_qualifiers: &HashSet<String>,
) -> Result<(), Error> {
let entries = fs::read_dir(dir)
.map_err(|e| Error::new(ErrorKind::Io, format!("cannot read {}: {e}", dir.display())))?;
Expand All @@ -198,7 +207,39 @@ fn walk_qualifier_dir(
let entry =
entry.map_err(|e| Error::new(ErrorKind::Io, format!("directory entry error: {e}")))?;
let path = entry.path();
if path.is_dir() || path.extension().unwrap_or_default() != "md" {

if path.is_dir() {
let subdir = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if valid_qualifiers.contains(&subdir) {
walk_qualifier_dir(
&path,
&subdir,
kind,
module_root,
sources,
base_filenames,
valid_qualifiers,
)?;
} else {
// Not an exact model ID from config/models.yaml. Surfacing this
// as a warning (rather than silently dropping the files, as
// before) lets modules still carrying short or retired model
// directory names see what is being skipped. Phase 2 hardens
// this to a validation error once those modules migrate to
// exact model IDs.
eprintln!(
"warning: skipping unknown model qualifier directory '{subdir}' in {} (expected an exact model ID from config/models.yaml)",
dir.display()
);
}
continue;
}

if path.extension().unwrap_or_default() != "md" {
continue;
}
let filename = path
Expand Down Expand Up @@ -323,8 +364,9 @@ fn collect_skill_files(

/// Build the set of valid qualifier names from provider names and model IDs.
///
/// A qualifier is valid if it matches either a provider name (e.g., "claude")
/// or a segment of any model ID (e.g., "sonnet" from "claude-sonnet-4-6").
/// A qualifier is valid if it is a provider name (e.g. `claude`) or an exact
/// model ID from `config/models.yaml` (e.g. `claude-opus-4-6`). Model IDs are
/// never split into segments: a directory named `4` or `6` is not a qualifier.
pub fn build_valid_qualifiers(
provider_names: &[String],
models: &std::collections::HashMap<String, Vec<String>>,
Expand All @@ -333,14 +375,8 @@ pub fn build_valid_qualifiers(
for name in provider_names {
qualifiers.insert(name.clone());
}
for model_ids in models.values() {
for model_id in model_ids {
for segment in model_id.split('-') {
if !segment.is_empty() {
qualifiers.insert(segment.to_string());
}
}
}
for model_id in models.values().flatten() {
qualifiers.insert(model_id.clone());
}
qualifiers
}
Expand Down Expand Up @@ -396,11 +432,54 @@ mod tests {
}

#[test]
fn build_qualifiers_includes_model_tier_segments() {
fn build_qualifiers_uses_exact_model_ids_not_segments() {
let providers = vec!["claude".to_string()];
let qualifiers = build_valid_qualifiers(&providers, &make_models());
assert!(qualifiers.contains("sonnet"));
assert!(qualifiers.contains("opus"));
assert!(qualifiers.contains("claude-opus-4-6"));
assert!(qualifiers.contains("claude-sonnet-4-6"));
// Segments of model IDs are not qualifiers — this is the junk-qualifier
// bug the exact-ID rule fixes.
assert!(!qualifiers.contains("sonnet"));
assert!(!qualifiers.contains("opus"));
assert!(!qualifiers.contains("4"));
assert!(!qualifiers.contains("6"));
}

#[test]
fn unknown_subdirectory_in_qualifier_dir_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let rules = scaffold_kind(dir.path(), "rules");
std::fs::write(rules.join("BaseRule.md"), BASE_RULE).unwrap();
let provider = rules.join("claude");
std::fs::create_dir(&provider).unwrap();
let stray = provider.join("not-a-model");
std::fs::create_dir(&stray).unwrap();
std::fs::write(stray.join("Stray.md"), QUALIFIER_ONLY).unwrap();

let valid = HashSet::from(["claude".to_string(), "claude-opus-4-6".to_string()]);
let sources = collect(dir.path(), &valid).expect("unknown subdir warns, not errors");
assert!(
sources.iter().all(|s| !s.relative_path.contains("Stray")),
"files under an unknown model qualifier dir are skipped"
);
}

#[test]
fn model_only_file_collected_with_model_qualifier() {
let dir = tempfile::tempdir().unwrap();
let rules = scaffold_kind(dir.path(), "rules");
std::fs::write(rules.join("BaseRule.md"), BASE_RULE).unwrap();
let model_dir = rules.join("claude").join("claude-opus-4-6");
std::fs::create_dir_all(&model_dir).unwrap();
std::fs::write(model_dir.join("OpusOnly.md"), QUALIFIER_ONLY).unwrap();

let valid = HashSet::from(["claude".to_string(), "claude-opus-4-6".to_string()]);
let sources = collect(dir.path(), &valid).unwrap();
let model_only = sources
.iter()
.find(|s| s.relative_path.contains("OpusOnly"))
.expect("model-only file must be collected, not dropped");
assert_eq!(model_only.qualifier, Some("claude-opus-4-6".to_string()));
}

#[test]
Expand Down
3 changes: 3 additions & 0 deletions src/cli/assemble/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ fn assemble_source_maps_agent_model_and_effort_tiers() {
&source,
std::path::Path::new("/tmp"),
"codex",
None,
&[
"name".to_string(),
"description".to_string(),
Expand Down Expand Up @@ -149,6 +150,7 @@ fn assemble_source_maps_all_codex_tiers() {
&source,
std::path::Path::new("/tmp"),
"codex",
None,
&[
"name".to_string(),
"description".to_string(),
Expand Down Expand Up @@ -205,6 +207,7 @@ fn assemble_source_keeps_explicit_effort_over_tier_effort() {
&source,
std::path::Path::new("/tmp"),
"codex",
None,
&[
"name".to_string(),
"description".to_string(),
Expand Down
5 changes: 3 additions & 2 deletions src/cli/install/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::deploy;
/// ```
///
/// Returns only the deployment result — assembly is an internal step.
#[allow(clippy::fn_params_excessive_bools)]
#[allow(clippy::fn_params_excessive_bools, clippy::too_many_arguments)]
pub fn execute(
path: &str,
target: Option<&str>,
Expand All @@ -21,8 +21,9 @@ pub fn execute(
prune: bool,
interactive: bool,
dry_run: bool,
model: Option<&str>,
) -> Result<ActionResult, Error> {
assemble::execute(path)?;
assemble::execute_with_model(path, model)?;
deploy::execute(
path,
target,
Expand Down
Loading