diff --git a/CHANGELOG.md b/CHANGELOG.md index cb86753..b6b35e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- The `steplock init` sample checklist and the `git-push-quality-gate` example matched the literal text `git push`, so `git -C push` (or any global option between `git` and `push`) skipped the gate. They now match on `command_words` + ## [0.1.0] - 2026-09-24 First published release. diff --git a/Installation.md b/Installation.md index f4f34c5..6756e21 100644 --- a/Installation.md +++ b/Installation.md @@ -101,7 +101,7 @@ Each checklist lives in its own subdirectory. The directory name is the checklis on_event = "tool:before" on_tool = "bash" -match_input = "input.command.contains('git push')" +match_input = "input.command_words.exists(x, x == 'git') && input.command_words.exists(x, x == 'push')" reset = "session" allow_preview_request = true ``` diff --git a/core/src/bin/main.rs b/core/src/bin/main.rs index b3a29c1..a83f217 100644 --- a/core/src/bin/main.rs +++ b/core/src/bin/main.rs @@ -170,7 +170,7 @@ fn run_hook() { const SAMPLE_CONFIG: &str = r#"on_event = "tool:before" on_tool = "bash" -match_input = "input.command.contains('git push')" +match_input = "input.command_words.exists(x, x == 'git') && input.command_words.exists(x, x == 'push')" reset = "session" "#; diff --git a/core/src/bin/main_tests.rs b/core/src/bin/main_tests.rs index 1f936c6..c8f238c 100644 --- a/core/src/bin/main_tests.rs +++ b/core/src/bin/main_tests.rs @@ -151,7 +151,7 @@ fn init_scaffolds_sample_checklist() { assert!(sample.join("config.toml").exists()); assert!(sample.join("flow.mmd").exists()); let cfg = fs::read_to_string(sample.join("config.toml")).unwrap(); - assert!(cfg.contains("git push")); + assert!(cfg.contains("command_words")); let flow = fs::read_to_string(sample.join("flow.mmd")).unwrap(); assert!(flow.contains("stateDiagram-v2")); } @@ -477,3 +477,28 @@ fn hermes_session_end_cleans_global_session() { "on_session_end must clean the global session" ); } + +#[test] +fn init_sample_blocks_git_push_with_global_options() { + let tmp = TempDir::new().unwrap(); + run_init(tmp.path()).unwrap(); + for cmd in [ + "git -C /repo push origin main", + "git push", + "cd x && git push -q", + ] { + let stdin = claude_stdin(cmd, "s-opts"); + let resp = run_app(stdin.as_bytes(), tmp.path(), None).unwrap(); + assert!( + matches!(resp, polyhook::HookResponse::BlockResponse(_)), + "sample must block `{cmd}`" + ); + fs::remove_dir_all(tmp.path().join(".steplock/sessions")).unwrap(); + } + let stdin = claude_stdin("git status", "s-opts"); + let resp = run_app(stdin.as_bytes(), tmp.path(), None).unwrap(); + assert!( + !matches!(resp, polyhook::HookResponse::BlockResponse(_)), + "sample must not block unrelated git commands" + ); +} diff --git a/examples/README.md b/examples/README.md index 3965954..dc00207 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,7 @@ A four-item quality checklist that gates `git push` commands. -**Trigger**: `tool:before` on `bash` when `input.command.contains('git push')`. +**Trigger**: `tool:before` on `bash` when the command's words include `git` and `push` (`command_words`, so `git -C dir push` and `git push` both match). **Flow**: linear — four states in sequence. diff --git a/examples/git-push-quality-gate/.steplock/checklists/git-push-quality-gate/config.toml b/examples/git-push-quality-gate/.steplock/checklists/git-push-quality-gate/config.toml index f66f047..53a2e75 100644 --- a/examples/git-push-quality-gate/.steplock/checklists/git-push-quality-gate/config.toml +++ b/examples/git-push-quality-gate/.steplock/checklists/git-push-quality-gate/config.toml @@ -2,6 +2,6 @@ on_event = "tool:before" on_tool = "bash" -match_input = "input.command.contains('git push')" +match_input = "input.command_words.exists(x, x == 'git') && input.command_words.exists(x, x == 'push')" reset = "session" allow_preview_request = true diff --git a/schemas/checklist-config.schema.json b/schemas/checklist-config.schema.json index 6214438..11ea6d3 100644 --- a/schemas/checklist-config.schema.json +++ b/schemas/checklist-config.schema.json @@ -29,7 +29,7 @@ "type": "string", "description": "Optional CEL expression evaluated against the hook event. Omit to match all invocations of on_event + on_tool. Available variables: input., output., event.tool, event.event, event.caller.", "examples": [ - "input.command.contains('git push')", + "input.command_words.exists(x, x == 'git') && input.command_words.exists(x, x == 'push')", "input.command.contains('git push') && !input.command.contains('--dry-run')", "input.command.matches('^git (push|tag)')", "input.path.startsWith('/etc')",