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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir> 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.
Expand Down
2 changes: 1 addition & 1 deletion Installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion core/src/bin/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
"#;

Expand Down
27 changes: 26 additions & 1 deletion core/src/bin/main_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Expand Down Expand Up @@ -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"
);
}
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion schemas/checklist-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>, output.<key>, 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')",
Expand Down
Loading