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
27 changes: 27 additions & 0 deletions .claude/flow.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"version": "1.0.0",
"task": {
"id": "output-style-validator",
"source": "manual",
"title": "PR2: output-style validator (post-#745 follow-on)"
},
"workflow": {
"id": "output-style-validator",
"status": "active",
"currentPhase": "setup",
"createdAt": "2026-04-22T00:00:00Z",
"updatedAt": "2026-04-22T00:00:00Z"
},
"git": {
"branch": "feat/output-style-validator",
"baseBranch": "main",
"baseSha": "3c4a3a9",
"mainRepoPath": "C:/Users/avife/agent-sh/agnix",
"isWorktree": true
},
"context": {
"description": "Add output-style validator for Claude Code output-style files (.claude/output-styles/*.md) - new FileType in agnix-core. Follow-on from triage of issue #745 which merged as PR #756. The keep-coding-instructions field was surfaced during #745 triage.",
"relatedIssue": "#745",
"relatedMergedPR": "#756"
}
}
78 changes: 78 additions & 0 deletions .exploration-report-pr2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# PR2 Exploration Report - Output-Style Validator

## A. Reference template

Picked: ClaudeRule (FileType::ClaudeRule, validator ClaudeRulesValidator, schema ClaudeRuleSchema)

Why:
- Same tool family (Claude Code), same path pattern (.claude/<subdir>/*.md)
- Only 2 rules (CC-MEM-011, CC-MEM-012) - simplest possible CC-family validator
- Self-contained YAML frontmatter schema using serde_yaml + heuristic unknown-key detection
- Valid and invalid fixtures exist under tests/fixtures/valid/claude-rules/ and invalid/claude-rules/
- No extra crate dependencies beyond serde_yaml
# Step map

1. types.rs - add ClaudeOutputStyle variant after ClaudeRule (line 42)
2. detection.rs - add output-styles arm after ClaudeRule arm (lines 381-386)
3. schemas/output_style.rs - new file, mirror claude_rules.rs
4. schemas/mod.rs - add pub mod output_style at line 33
5. rules/output_style.rs - new file, mirror claude_rules.rs
6. rules/mod.rs - add pub mod output_style at line 32
7. registry.rs - EXPECTED_BUILTIN_COUNT 73->74, new factory fn, ClaudeProvider entry, test array 42->43
8. knowledge-base/rules.json - increment total_rules, add N CC-OS entries
9. crates/agnix-rules/rules.json - cp from knowledge-base/rules.json
10. knowledge-base/VALIDATION-RULES.md - new section, footer count
11. tests/fixtures/ - valid and invalid output-style fixtures
12. output_style.rs tests - co-located #[cfg(test)] mod tests

# Additional files

- config/rule_filter.rs: add CC-OS- branch to is_category_enabled()
- config.rs: add output_styles: bool to RuleConfig
- rule_parity.rs: add CC-OS- to valid_prefixes, claude-output-styles to valid_categories, fixture mapping
# B. Naming confirmations

CC-OS- prefix: Zero existing rules, no collision confirmed.
FileType::ClaudeOutputStyle: Matches convention (ClaudeRule, ClaudeMd).
OutputStyleSchema: Consistent with ClaudeRuleSchema.
OutputStyleValidator: Consistent with ClaudeRulesValidator.

# C. Implementation gotchas

1. EXPECTED_BUILTIN_COUNT sentinel (registry.rs line 401): +1 per validator entry. 73->74. Test array [FileType; 42] -> [FileType; 43].

2. Exhaustive match arrays in types.rs tests: three functions list every FileType variant in typed arrays; all must include ClaudeOutputStyle or compile fails.

3. rule_parity.rs valid_prefixes (line 167): CC-OS- must be listed or test_all_rules_implemented fails.

4. rule_parity.rs valid_categories (line 509): test_rules_json_integrity panics on unknown category; add claude-output-styles.

5. rule_parity.rs infer_fixture_coverage (line 280): test_fixture_coverage_exists fails without claude-output-styles -> directory mapping.

6. i18n locale files: Avoid rust_i18n::t!() for CC-OS rules; use inline strings to skip editing en.yml, es.yml, zh-CN.yml.

7. Hyphenated YAML key keep-coding-instructions: Cannot use #[serde(rename)] with hyphens. Extract manually from serde_yaml::Mapping as done for paths in claude_rules.rs.

8. VALIDATION-RULES.md anchor: Parity test looks for <a id=cc-os-001></a> (lowercase ID).

9. Autofix footer count: stays at 126 if no CC-OS rules have autofix:true.

10. Clippy Rust 1.95 -D warnings (CI stricter than local 1.92): avoid unnecessary_sort_by (use .sort()), needless_borrows_for_generic_args, unnecessary_literal_unwrap (use .expect()), gate unix-only imports with #[cfg(unix)].

11. total_rules in rules.json: test_rules_json_integrity asserts total_rules == rules.len(); always sync.

12. test_sum_of_category_providers_equals_expected (registry.rs line 1947): Must match EXPECTED_BUILTIN_COUNT; ClaudeProvider entry and constant must change together.

13. Safe execution order: schema -> validator -> file_types -> registry -> config.rs -> rule_filter.rs -> rules.json (both copies) -> VALIDATION-RULES.md -> fixtures -> rule_parity.rs. Run cargo test --workspace before adding fixtures.

# D. Proposed rule set (CC-OS-001 through CC-OS-005)

| ID | Name | Severity | Normative | Checks | Bad example |
|---|---|---|---|---|---|
| CC-OS-001 | Missing Output Style Description | LOW | SHOULD | description absent or empty; needed for /config picker label | name without description in frontmatter |
| CC-OS-002 | Invalid keep-coding-instructions Type | HIGH | MUST | keep-coding-instructions present but not YAML boolean; e.g. string yes, number 1, null | keep-coding-instructions: yes (quoted string) |
| CC-OS-003 | Unknown Output Style Frontmatter Key | MEDIUM | SHOULD | Top-level key not in {name, description, keep-coding-instructions}; signals typo or wrong file type | paths: - src/** (a rules field, not output-style) |
| CC-OS-004 | Empty Output Style Body | MEDIUM | SHOULD | Body after closing --- (or entire file if no frontmatter) is blank/whitespace-only; dead config | frontmatter with blank body |
| CC-OS-005 | Output Style Name Too Long | LOW | SHOULD | name value exceeds 64 chars; truncates in /config picker UI | name: this-is-a-very-long-style-name-exceeding-64-chars |

Omissions: name format constraints not documented; name not required (defaults to filename); CC-OS-006 (name+filename) over-specified for optional field.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Output-style validator (CC-OS-001..005)** - new validator for `.claude/output-styles/*.md` files, surfaced during the Claude Code v2.1.117 triage (#745). The output-style frontmatter spec was added in v2.1.94 with the `keep-coding-instructions` field. New rules: CC-OS-001 missing description (LOW), CC-OS-002 invalid `keep-coding-instructions` type (HIGH), CC-OS-003 unknown frontmatter key (MEDIUM), CC-OS-004 empty body (MEDIUM), CC-OS-005 name exceeds 64 chars (LOW). All non-autofix. New `FileType::ClaudeOutputStyle`, `OutputStyleSchema`, `OutputStyleValidator`. Total rules: 399 -> 404.
- **Daily tool-release watcher** (`.github/workflows/tool-release-watch.yml`, `scripts/check-tool-releases.sh`, `scripts/glm-extract.js`) - polls every supported tool's release feed daily at 7am UTC and opens a per-tool issue when a new release is detected. Supports four source types via `.github/tool-release-baselines.json`: GitHub releases (claude-code, codex, opencode, copilot via `microsoft/vscode-copilot-chat`, cline, roo-code, gemini-cli), JSON update endpoint (cursor's `api2.cursor.sh` stable channel), HTML scrape with regex (kiro, windsurf), and RSS slug (amp). Per-tool `notes_extractor` selects between LLM-extracted markdown via GLM (`glm-5` by default; activates only when `GLM_API_KEY` repo secret is set), `<item><description>` CDATA parse, or stub link. Issues are deduped by `tool-release:<id>` label so re-runs comment on existing open issues. `workflow_dispatch` accepts `tool` filter and `update_baselines: true` toggle.
- **Playground state in URL** - playground configs are now shareable by link via `?file=`, `?tool=`, and `?code=` querystring params (URL-safe base64 for UTF-8 content, debounced writes, capped at 6 KB to stay URL-safe).

Expand Down
7 changes: 4 additions & 3 deletions crates/agnix-cli/tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,16 @@ fn test_format_sarif_has_all_rules() {

// Use threshold range to avoid brittleness when rules are added/removed,
// while still catching major regressions (missing rules) or explosions.
// As of writing, there are ~385 rules documented in VALIDATION-RULES.md.
// As of writing, there are ~405 rules documented in VALIDATION-RULES.md;
// the upper bound has ~10% headroom against accidental explosion.
assert!(
rules.len() >= 70,
"Expected at least 70 validation rules, found {} (possible rule registration bug)",
rules.len()
);
assert!(
rules.len() <= 400,
"Expected at most 400 validation rules, found {} (unexpected rule explosion)",
rules.len() <= 450,
"Expected at most 450 validation rules, found {} (unexpected rule explosion)",
Comment on lines 197 to +199

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion upper bound was bumped here, but the nearby comment still says “~385 rules” (it’s now 404). Please update that comment to match current reality so the threshold rationale stays accurate.

Copilot uses AI. Check for mistakes.
rules.len()
);

Expand Down
13 changes: 9 additions & 4 deletions crates/agnix-cli/tests/rule_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,10 @@ fn extract_implemented_rule_ids() -> BTreeSet<String> {

// Known rule ID prefixes to filter out false positives
let valid_prefixes = [
"AS-", "CC-SK-", "CC-HK-", "CC-AG-", "CC-MEM-", "CC-PL-", "AGM-", "MCP-", "COP-", "CUR-",
"CLN-", "CDX-", "OC-", "GM-", "XML-", "REF-", "PE-", "XP-", "VER-", "WS-", "CR-SK-",
"CL-SK-", "CP-SK-", "CX-SK-", "OC-SK-", "WS-SK-", "KR-SK-", "KR-AG-", "KR-HK-", "KR-PW-",
"KR-MCP-", "KIRO-", "AMP-SK-", "AMP-", "RC-SK-", "ROO-",
"AS-", "CC-SK-", "CC-HK-", "CC-AG-", "CC-MEM-", "CC-OS-", "CC-PL-", "AGM-", "MCP-", "COP-",
"CUR-", "CLN-", "CDX-", "OC-", "GM-", "XML-", "REF-", "PE-", "XP-", "VER-", "WS-",
"CR-SK-", "CL-SK-", "CP-SK-", "CX-SK-", "OC-SK-", "WS-SK-", "KR-SK-", "KR-AG-", "KR-HK-",
"KR-PW-", "KR-MCP-", "KIRO-", "AMP-SK-", "AMP-", "RC-SK-", "ROO-",
];

fn extract_from_file(
Expand Down Expand Up @@ -289,6 +289,10 @@ fn infer_fixture_coverage(rules: &[RuleEntry]) -> HashMap<String, Vec<String>> {
("claude-hooks", vec!["valid/hooks", "invalid/hooks"]),
("claude-agents", vec!["valid/agents", "invalid/agents"]),
("claude-memory", vec!["valid/memory", "invalid/memory"]),
(
"claude-output-styles",
vec!["valid/output-styles", "invalid/output-styles"],
),
("claude-plugins", vec!["valid/plugins", "invalid/plugins"]),
("agents-md", vec!["agents_md"]),
("mcp", vec!["mcp"]),
Expand Down Expand Up @@ -512,6 +516,7 @@ fn test_rules_json_integrity() {
"claude-hooks",
"claude-agents",
"claude-memory",
"claude-output-styles",
"agents-md",
"claude-plugins",
"mcp",
Expand Down
6 changes: 6 additions & 0 deletions crates/agnix-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,11 @@ pub struct RuleConfig {
#[schemars(description = "Enable Claude Code memory validation rules (CC-MEM-*)")]
pub memory: bool,

/// Enable output-styles validation (CC-OS-*)
#[serde(default = "default_true")]
#[schemars(description = "Enable Claude Code output-style validation rules (CC-OS-*)")]
pub output_styles: bool,

/// Enable plugins validation (CC-PL-*)
#[serde(default = "default_true")]
#[schemars(description = "Enable Claude Code plugins validation rules (CC-PL-*)")]
Expand Down Expand Up @@ -661,6 +666,7 @@ impl Default for RuleConfig {
hooks: true,
agents: true,
memory: true,
output_styles: true,
plugins: true,
xml: true,
mcp: true,
Expand Down
1 change: 1 addition & 0 deletions crates/agnix-core/src/config/rule_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ impl<'a> DefaultRuleFilter<'a> {
s if s.starts_with("CC-HK-") => self.rules.hooks,
s if s.starts_with("CC-AG-") => self.rules.agents,
s if s.starts_with("CC-MEM-") => self.rules.memory,
s if s.starts_with("CC-OS-") => self.rules.output_styles,
s if s.starts_with("CC-PL-") => self.rules.plugins,
s if s.starts_with("XML-") => self.rules.xml,
s if s.starts_with("MCP-") => self.rules.mcp,
Expand Down
24 changes: 24 additions & 0 deletions crates/agnix-core/src/file_types/detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ pub fn detect_file_type(path: &Path) -> FileType {
{
FileType::ClaudeRule
}
// Claude Code output styles (.claude/output-styles/*.md)
name if name.ends_with(".md")
&& parent == Some("output-styles")
&& grandparent == Some(".claude") =>
{
FileType::ClaudeOutputStyle
}
// Cursor project rules (.cursor/rules/**/*.md and .mdc)
name if (name.ends_with(".md") || name.ends_with(".mdc"))
&& is_under_cursor_rules(path) =>
Expand Down Expand Up @@ -787,6 +794,23 @@ mod tests {
);
}

#[test]
fn detect_claude_output_style() {
assert_eq!(
detect_file_type(Path::new(".claude/output-styles/concise.md")),
FileType::ClaudeOutputStyle
);
}

#[test]
fn detect_claude_output_style_not_rules_dir() {
// .claude/rules/*.md must remain ClaudeRule, NOT ClaudeOutputStyle.
assert_eq!(
detect_file_type(Path::new(".claude/rules/custom.md")),
FileType::ClaudeRule
);
}

#[test]
fn detect_amp_check() {
assert_eq!(
Expand Down
6 changes: 6 additions & 0 deletions crates/agnix-core/src/file_types/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ pub enum FileType {
CopilotHooks,
/// Claude Code rules (.claude/rules/*.md)
ClaudeRule,
/// Claude Code output style files (.claude/output-styles/*.md)
ClaudeOutputStyle,
/// Cursor project rules (.cursor/rules/*.md, .cursor/rules/*.mdc, including nested dirs)
CursorRule,
/// Cursor hooks configuration (.cursor/hooks.json)
Expand Down Expand Up @@ -141,6 +143,7 @@ impl fmt::Display for FileType {
FileType::CopilotPrompt => "CopilotPrompt",
FileType::CopilotHooks => "CopilotHooks",
FileType::ClaudeRule => "ClaudeRule",
FileType::ClaudeOutputStyle => "ClaudeOutputStyle",
FileType::CursorRule => "CursorRule",
FileType::CursorHooks => "CursorHooks",
FileType::CursorAgent => "CursorAgent",
Expand Down Expand Up @@ -196,6 +199,7 @@ mod tests {
(FileType::CopilotPrompt, "CopilotPrompt"),
(FileType::CopilotHooks, "CopilotHooks"),
(FileType::ClaudeRule, "ClaudeRule"),
(FileType::ClaudeOutputStyle, "ClaudeOutputStyle"),
(FileType::CursorRule, "CursorRule"),
(FileType::CursorHooks, "CursorHooks"),
(FileType::CursorAgent, "CursorAgent"),
Expand Down Expand Up @@ -250,6 +254,7 @@ mod tests {
FileType::CopilotPrompt,
FileType::CopilotHooks,
FileType::ClaudeRule,
FileType::ClaudeOutputStyle,
FileType::CursorRule,
FileType::CursorHooks,
FileType::CursorAgent,
Expand Down Expand Up @@ -318,6 +323,7 @@ mod tests {
FileType::CopilotPrompt,
FileType::CopilotHooks,
FileType::ClaudeRule,
FileType::ClaudeOutputStyle,
FileType::CursorRule,
FileType::CursorHooks,
FileType::CursorAgent,
Expand Down
17 changes: 14 additions & 3 deletions crates/agnix-core/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ impl ValidatorRegistryBuilder {
///
/// Used by `BuiltinProvider` (via `debug_assert_eq!`) and tests to catch
/// accidental additions or removals without updating all providers.
const EXPECTED_BUILTIN_COUNT: usize = 73;
const EXPECTED_BUILTIN_COUNT: usize = 74;

// -- Category providers -----------------------------------------------------
//
Expand Down Expand Up @@ -482,6 +482,11 @@ impl ValidatorProvider for ClaudeProvider {
Some("ClaudeRulesValidator"),
claude_rules_validator,
),
(
FileType::ClaudeOutputStyle,
Some("OutputStyleValidator"),
output_style_validator,
),
]
}
}
Expand Down Expand Up @@ -882,6 +887,10 @@ fn claude_rules_validator() -> Box<dyn Validator> {
Box::new(crate::rules::claude_rules::ClaudeRulesValidator)
}

fn output_style_validator() -> Box<dyn Validator> {
Box::new(crate::rules::output_style::OutputStyleValidator)
}

fn cursor_validator() -> Box<dyn Validator> {
Box::new(crate::rules::cursor::CursorValidator)
}
Expand Down Expand Up @@ -1355,7 +1364,7 @@ mod tests {

#[test]
fn every_validatable_file_type_has_at_least_one_validator() {
let validatable_types: [FileType; 42] = [
let validatable_types: [FileType; 43] = [
FileType::Skill,
FileType::ClaudeMd,
FileType::Agent,
Expand All @@ -1369,6 +1378,7 @@ mod tests {
FileType::CopilotPrompt,
FileType::CopilotHooks,
FileType::ClaudeRule,
FileType::ClaudeOutputStyle,
FileType::CursorRule,
FileType::CursorHooks,
FileType::CursorAgent,
Expand Down Expand Up @@ -1417,6 +1427,7 @@ mod tests {
| FileType::CopilotPrompt
| FileType::CopilotHooks
| FileType::ClaudeRule
| FileType::ClaudeOutputStyle
| FileType::CursorRule
| FileType::CursorHooks
| FileType::CursorAgent
Expand Down Expand Up @@ -1903,7 +1914,7 @@ mod tests {

#[test]
fn claude_provider_count() {
assert_eq!(ClaudeProvider.named_validators().len(), 11);
assert_eq!(ClaudeProvider.named_validators().len(), 12);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/agnix-core/src/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod kiro_power;
pub mod kiro_steering;
pub mod mcp;
pub mod opencode;
pub mod output_style;
pub mod per_client_skill;
pub mod plugin;
pub mod project_level;
Expand Down
Loading
Loading