diff --git a/.claude/skills/oo-learn-patterns/SKILL.md b/.claude/skills/oo-learn-patterns/SKILL.md new file mode 100644 index 0000000..55ec1ea --- /dev/null +++ b/.claude/skills/oo-learn-patterns/SKILL.md @@ -0,0 +1,256 @@ +--- +name: oo-learn-patterns +description: >- + Bootstrap project-specific oo output patterns for a repository. Scans the repo + to detect its toolchain and creates .oo/patterns/*.toml files that compress + verbose command output for AI agents. Use when you want to set up oo patterns + for a project, teach oo about a repo's commands, or create .oo/patterns. +--- + +# oo-learn-patterns + +Create project-specific output patterns so `oo` can compress verbose command +output into terse summaries for AI coding agents. + +## Workflow + +### 1. Detect project root and toolchain + +Find the git root (`git rev-parse --show-toplevel`) and scan for toolchain +markers to determine which commands the project uses: + +| Marker file | Commands to pattern | +|-------------|-------------------| +| `Cargo.toml` | `cargo test`, `cargo build`, `cargo clippy`, `cargo fmt --check` | +| `package.json` | `npm test`, `npm run build`, `npx jest`, `npx eslint`, `npx tsc` | +| `pyproject.toml` / `setup.py` / `requirements.txt` | `pytest`, `ruff check`, `mypy`, `pip install` | +| `go.mod` | `go test ./...`, `go build ./...`, `go vet ./...` | +| `Makefile` / `CMakeLists.txt` | `make`, `cmake --build` | +| `Dockerfile` / `docker-compose.yml` | `docker build`, `docker compose up` | +| `terraform/` / `*.tf` | `terraform plan`, `terraform apply` | +| `.github/workflows/` | inspect YAML for additional commands | + +Also check for CI config files (`.github/workflows/*.yml`, `.gitlab-ci.yml`, +`Jenkinsfile`) to discover commands actually used in the project. + +### 2. Create the patterns directory + +```bash +mkdir -p /.oo/patterns +``` + +### 3. Author one `.toml` file per command + +For each discovered command, create a pattern file in `.oo/patterns/`. +Name files descriptively: `cargo-test.toml`, `npm-build.toml`, etc. + +Use the TOML format reference below. Key principles: + +- `command_match` is a regex tested against the full command string +- `[success]` extracts a terse summary from passing output via named captures +- `[failure]` filters noisy failure output to show only actionable lines +- An empty `summary = ""` suppresses output entirely on success (quiet pass) +- Omit `[failure]` to show all output on failure (sensible default) + +### 4. Validate patterns + +After creating patterns, verify them: + +```bash +oo patterns # lists all loaded patterns (project + user + builtins) +oo # run a real command to test the pattern +``` + +### 5. Iterate + +If a pattern doesn't match as expected, adjust `command_match` regex or +`[success].pattern` captures. Run the command again with `oo` to verify. + +You can also use `oo learn ` to have an LLM generate a pattern +(saves to `~/.config/oo/patterns/`, not project-local), then move or +adapt the generated file into `.oo/patterns/`. + +--- + +## Pattern TOML Format Reference + +Patterns are `.toml` files — one per command. They are loaded from two locations +in order, with the first regex match winning: + +1. **Project:** `/.oo/patterns/` — repo-specific, checked in with the project +2. **User:** `~/.config/oo/patterns/` — personal patterns across all projects + +Both layers are checked before built-in patterns, so custom patterns always override. + +### TOML format + +```toml +# Regex matched against the full command string (e.g. "make -j4 all") +command_match = "^make\\b" + +[success] +# Regex with named captures run against stdout+stderr +pattern = '(?P\S+) is up to date' +# Template: {name} is replaced with the capture of the same name +summary = "{target} up to date" + +[failure] +# Strategy: tail | head | grep | between +strategy = "grep" +# For grep: lines matching this regex are kept +grep = "Error:|error\\[" +``` + +### `[success]` section + +| Field | Type | Description | +|-------|------|-------------| +| `pattern` | regex | Named captures become template variables | +| `summary` | string | Template; `{capture_name}` replaced at runtime | + +An empty `summary = ""` suppresses output on success (quiet pass). + +### `[failure]` section + +`strategy` is optional and defaults to `"tail"`. + +| `strategy` | Extra fields | Behaviour | +|------------|-------------|-----------| +| `tail` | `lines` (default 30) | Last N lines of output | +| `head` | `lines` (default 20) | First N lines of output | +| `grep` | `grep` (regex, required) | Lines matching regex | +| `between` | `start`, `end` (strings, required) | Lines from first `start` match to first `end` match (inclusive) | + +Omit `[failure]` to show all output on failure. + +> **Note:** `start` and `end` in the `between` strategy are plain substring +> matches, not regexes. + +### Command Categories + +oo categorizes commands to determine default behavior when no pattern matches: + +| Category | Examples | Default Behavior | +|----------|----------|------------------| +| **Status** | `cargo test`, `pytest`, `eslint`, `cargo build` | Quiet success (empty summary) if output > 4 KB | +| **Content** | `git show`, `git diff`, `cat`, `bat` | Always pass through, never index | +| **Data** | `git log`, `git status`, `gh api`, `ls`, `find` | Index for recall if output > 4 KB and unpatterned | +| **Unknown** | Anything else (curl, docker, etc.) | Pass through (safe default) | + +Patterns always take priority over category defaults. If a pattern matches, it +determines the output classification regardless of category. + +--- + +## Example Patterns + +### `docker build` + +```toml +command_match = "\\bdocker\\s+build\\b" + +[success] +pattern = 'Successfully built (?P[0-9a-f]+)' +summary = "built {id}" + +[failure] +strategy = "tail" +lines = 20 +``` + +### `terraform plan` + +```toml +command_match = "\\bterraform\\s+plan\\b" + +[success] +pattern = 'Plan: (?P\d+) to add, (?P\d+) to change, (?P\d+) to destroy' +summary = "+{add} ~{change} -{destroy}" + +[failure] +strategy = "grep" +grep = "Error:|error:" +``` + +### `make` + +```toml +command_match = "^make\\b" + +[success] +pattern = '(?s).*' # always matches; empty summary = quiet +summary = "" + +[failure] +strategy = "between" +start = "make[" +end = "Makefile:" +``` + +--- + +## Example: Programmatic Pattern Usage (Rust) + +This shows how patterns work at the library level, useful for understanding +the internal structure when authoring TOML patterns. + +```rust +use double_o::pattern::parse_pattern_str; +use double_o::{CommandOutput, classify}; + +fn main() { + let toml_pattern = r#" +command_match = "^myapp test" + +[success] +pattern = '(?P\d+) tests passed, (?P\d+) failed' +summary = "{passed} passed, {failed} failed" + +[failure] +strategy = "tail" +lines = 20 +"#; + + let custom_pattern = parse_pattern_str(toml_pattern).unwrap(); + + let success_output = CommandOutput { + stdout: br"Running test suite... +Test 1... OK +Test 2... OK +Test 3... OK +Result: 42 tests passed, 0 failed +Total time: 2.5s" + .to_vec(), + stderr: Vec::new(), + exit_code: 0, + }; + + let patterns = vec![custom_pattern]; + let classification = classify(&success_output, "myapp test --verbose", &patterns); + + match &classification { + double_o::Classification::Success { label, summary } => { + println!(" Label: {}", label); // "myapp" + println!(" Summary: {}", summary); // "42 passed, 0 failed" + } + _ => println!(" Unexpected classification type"), + } +} +``` + +A grep-based failure strategy example: + +```rust +let grep_pattern = r#" +command_match = "^myapp build" + +[success] +pattern = 'Build complete' +summary = "build succeeded" + +[failure] +strategy = "grep" +grep = "Error:" +"#; +// Only lines matching "Error:" are kept in the failure output. +``` diff --git a/docs/patterns.md b/docs/patterns.md index b77ed9a..4954c7a 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -1,7 +1,12 @@ # Custom Patterns -Patterns live in `~/.config/oo/patterns/` as `.toml` files — one per command. -User patterns are checked before built-ins, so they override existing behaviour. +Patterns are `.toml` files — one per command. They are loaded from two locations +in order, with the first regex match winning: + +1. **Project:** `/.oo/patterns/` — repo-specific, checked in with the project +2. **User:** `~/.config/oo/patterns/` — personal patterns across all projects + +Both layers are checked before built-in patterns, so custom patterns always override. ## TOML format diff --git a/src/commands.rs b/src/commands.rs index 807b231..acb365e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -65,10 +65,15 @@ pub fn cmd_run(args: &[String]) -> i32 { return 1; } - // Load patterns: user patterns first (override), then builtins + // Load patterns: project-local first, then user config, then builtins. + // First match wins, so project patterns override user patterns override builtins. + let project_patterns = load_project_patterns(); let user_patterns = pattern::load_user_patterns(&learn::patterns_dir()); let builtin_patterns = pattern::builtins(); let mut all_patterns: Vec<&pattern::Pattern> = Vec::new(); + for p in &project_patterns { + all_patterns.push(p); + } for p in &user_patterns { all_patterns.push(p); } @@ -406,16 +411,13 @@ pub fn check_and_clear_learn_status(status_path: &Path) { } } -/// List learned pattern files from the given directory. +/// Print pattern entries from a single directory, returning true if any were found. /// -/// Extracted for testability — callers pass in the resolved patterns dir. -pub fn cmd_patterns_in(dir: &Path) -> i32 { +/// Each line is printed with a two-space indent so callers can add section headers. +pub fn list_patterns_in(dir: &Path) -> bool { let entries = match std::fs::read_dir(dir) { Ok(e) => e, - Err(_) => { - println!("no learned patterns yet"); - return 0; - } + Err(_) => return false, }; let mut found = false; @@ -424,7 +426,6 @@ pub fn cmd_patterns_in(dir: &Path) -> i32 { if path.extension().and_then(|e| e.to_str()) != Some("toml") { continue; } - // Parse the file once, extract all three fields from the single Value let parsed = std::fs::read_to_string(&path) .ok() .and_then(|s| toml::from_str::(&s).ok()); @@ -435,7 +436,6 @@ pub fn cmd_patterns_in(dir: &Path) -> i32 { let has_success = parsed.as_ref().and_then(|v| v.get("success")).is_some(); let has_failure = parsed.as_ref().and_then(|v| v.get("failure")).is_some(); - // Only mark found after a valid parse; skip corrupt files silently if parsed.is_none() { continue; } @@ -450,20 +450,50 @@ pub fn cmd_patterns_in(dir: &Path) -> i32 { flags.push("failure"); } if flags.is_empty() { - println!("{cmd_match}"); + println!(" {cmd_match}"); } else { - println!("{cmd_match} [{}]", flags.join("] [")); + println!(" {cmd_match} [{}]", flags.join("] [")); } } + found +} - if !found { +/// List learned pattern files from a single directory (legacy test helper). +pub fn cmd_patterns_in(dir: &Path) -> i32 { + if !list_patterns_in(dir) { println!("no learned patterns yet"); } 0 } +/// List patterns from both project-local and user config directories. pub fn cmd_patterns() -> i32 { - cmd_patterns_in(&learn::patterns_dir()) + let project_dir = std::env::current_dir() + .map(|cwd| init::project_patterns_dir(&cwd)) + .ok(); + let user_dir = learn::patterns_dir(); + + let mut total_found = false; + + if let Some(ref pdir) = project_dir { + if pdir.exists() { + println!("Project ({}):", pdir.display()); + if list_patterns_in(pdir) { + total_found = true; + } + println!(); + } + } + + println!("User ({}):", user_dir.display()); + if list_patterns_in(&user_dir) { + total_found = true; + } + + if !total_found { + println!("no patterns yet"); + } + 0 } pub fn cmd_help(cmd: &str) -> i32 { @@ -489,6 +519,17 @@ pub fn cmd_init(format: InitFormat) -> i32 { } } +/// Load project-local patterns from `/.oo/patterns/`. +/// +/// Returns an empty vec when cwd cannot be determined or the directory +/// does not exist (gracefully handled by `load_user_patterns`). +pub fn load_project_patterns() -> Vec { + let Ok(cwd) = std::env::current_dir() else { + return Vec::new(); + }; + pattern::load_user_patterns(&init::project_patterns_dir(&cwd)) +} + #[cfg(test)] #[path = "commands_tests.rs"] mod tests; diff --git a/src/init.rs b/src/init.rs index 51ce053..ae37b76 100644 --- a/src/init.rs +++ b/src/init.rs @@ -68,6 +68,14 @@ pub const HOOKS_JSON: &str = r#"{ } "#; +/// Resolve the project-local patterns directory: `/.oo/patterns`. +/// +/// Used by the pattern loader to pick up repo-specific patterns before +/// user-global ones (`~/.config/oo/patterns`). +pub fn project_patterns_dir(cwd: &Path) -> PathBuf { + find_root(cwd).join(".oo").join("patterns") +} + /// Resolve the directory in which to create `.claude/`. /// /// Walks upward from `cwd` looking for a `.git` directory — this is the git @@ -304,6 +312,28 @@ mod tests { assert_eq!(find_root(dir.path()), dir.path()); } + // ----------------------------------------------------------------------- + // project_patterns_dir + // ----------------------------------------------------------------------- + + #[test] + fn project_patterns_dir_is_under_git_root() { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join(".git")).unwrap(); + let sub = dir.path().join("a").join("b"); + fs::create_dir_all(&sub).unwrap(); + + let result = project_patterns_dir(&sub); + assert_eq!(result, dir.path().join(".oo").join("patterns")); + } + + #[test] + fn project_patterns_dir_no_git_uses_cwd() { + let dir = TempDir::new().unwrap(); + let result = project_patterns_dir(dir.path()); + assert_eq!(result, dir.path().join(".oo").join("patterns")); + } + // ----------------------------------------------------------------------- // run_in — happy path // ----------------------------------------------------------------------- diff --git a/src/lib.rs b/src/lib.rs index fcef3a1..4a4c508 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,8 +13,9 @@ //! and output size. Small successful outputs pass through verbatim, while large outputs //! are pattern-matched to extract terse summaries or indexed for later recall. //! - **Patterns**: Regular expressions define how to extract summaries from command output. -//! Built-in patterns exist for common tools (pytest, cargo test, npm test, etc.), and -//! user-defined patterns can be loaded from TOML files in `~/.config/oo/patterns/`. +//! Built-in patterns exist for common tools (pytest, cargo test, npm test, etc.). +//! Custom patterns can be loaded from project-local `/.oo/patterns/` or +//! user-global `~/.config/oo/patterns/` TOML files (project patterns take precedence). //! - **Storage**: Large unpatterned outputs are stored in a searchable database (SQLite by //! default, with optional Vipune semantic search). Stored outputs can be recalled with //! full-text search. @@ -93,8 +94,8 @@ pub use store::{SessionMeta, Store}; #[doc(hidden)] pub use commands::{ Action, InitFormat, check_and_clear_learn_status, classify_with_refs, cmd_forget, cmd_help, - cmd_init, cmd_learn, cmd_patterns, cmd_patterns_in, cmd_recall, cmd_run, parse_action, - try_index, write_learn_status, + cmd_init, cmd_learn, cmd_patterns, cmd_patterns_in, cmd_recall, cmd_run, load_project_patterns, + parse_action, try_index, write_learn_status, }; // Internal type re-exported for learn module diff --git a/tests/integration.rs b/tests/integration.rs index fd97677..d3c2311 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -444,15 +444,14 @@ fn test_dispatch_init() { #[test] fn test_patterns_no_learned_patterns() { - // When no patterns exist (or patterns dir is absent), exit 0 and print "no learned patterns yet" - // Set XDG_CONFIG_HOME so that dirs::config_dir() on Linux respects the temp HOME. + // When no patterns exist (or patterns dir is absent), exit 0 and print "no patterns yet" let dir = TempDir::new().unwrap(); oo().arg("patterns") .env("HOME", dir.path()) .env("XDG_CONFIG_HOME", dir.path().join(".config")) .assert() .success() - .stdout(predicate::str::contains("no learned patterns yet")); + .stdout(predicate::str::contains("no patterns yet")); } #[test] @@ -537,3 +536,57 @@ fn test_help_with_valid_command() { .success() .stdout(predicate::str::is_empty().not()); } + +// --------------------------------------------------------------------------- +// project patterns (.oo/patterns) +// --------------------------------------------------------------------------- + +#[test] +fn test_project_patterns_listed_when_present() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); + let pat_dir = dir.path().join(".oo").join("patterns"); + std::fs::create_dir_all(&pat_dir).unwrap(); + std::fs::write( + pat_dir.join("mytest.toml"), + "command_match = \"^mytest\"\n[success]\npattern = '(?P\\d+) ok'\nsummary = \"{n} ok\"\n", + ) + .unwrap(); + + oo().arg("patterns") + .current_dir(dir.path()) + .env("HOME", dir.path()) + .env("XDG_CONFIG_HOME", dir.path().join(".config")) + .assert() + .success() + .stdout(predicate::str::contains("Project")) + .stdout(predicate::str::contains("^mytest")); +} + +#[test] +fn test_project_patterns_override_builtins() { + // Create a project pattern for "echo" with a success pattern that matches + // the output, proving project patterns are loaded and take effect. + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join(".git")).unwrap(); + let pat_dir = dir.path().join(".oo").join("patterns"); + std::fs::create_dir_all(&pat_dir).unwrap(); + + // A pattern that matches "echo" and summarises any output as "proj-match" + std::fs::write( + pat_dir.join("echo.toml"), + "command_match = \"^echo\\\\b\"\n[success]\npattern = '(?s)(?P.+)'\nsummary = \"proj-match\"\n", + ) + .unwrap(); + + // Generate enough output to exceed SMALL_THRESHOLD so the pattern is consulted. + // SMALL_THRESHOLD is 4096 bytes; we need > 4096 bytes of output. + let big_arg = "x".repeat(5000); + oo().args(["echo", &big_arg]) + .current_dir(dir.path()) + .env("HOME", dir.path()) + .env("XDG_CONFIG_HOME", dir.path().join(".config")) + .assert() + .success() + .stdout(predicate::str::contains("proj-match")); +}