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]

### Changed
- `steplock init` and `steplock init --global` no longer write a default `example-gate` checklist. They create an empty `checklists/` directory

### 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`

Expand Down
6 changes: 3 additions & 3 deletions Installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ cargo install steplock

## Project setup

Run inside your repo root. Creates the `.steplock/checklists/` directory tree:
Run inside your repo root. Creates an empty `.steplock/checklists/` directory and a `.steplock/.gitignore`. No checklist is written:

```sh
steplock init
Expand Down Expand Up @@ -177,7 +177,7 @@ Use the full path to the binary (`which steplock`; `~/.cargo/bin/steplock` after
```sh
echo '{"args": {"command": "git push origin main"}, "session_id": "check"}' > /tmp/push.json
hermes hooks test pre_tool_call --for-tool terminal --payload-file /tmp/push.json
# parsed (Hermes wire shape): {"action": "block", "message": "[example-gate: 1/2] ..."}
# parsed (Hermes wire shape): {"action": "block", "message": "[<checklist>: 1/N] ..."}
```

Hermes runs hooks in the directory where Hermes started, not in the terminal tool's working directory. Global checklists do not depend on this. A project `.steplock/` is found only when Hermes was started inside that project.
Expand Down Expand Up @@ -209,7 +209,7 @@ cat .steplock/audit.log

## Global checklists

Global checklists apply to every project. Create the global steplock directory with a sample checklist:
Global checklists apply to every project. Create the global steplock directory (with an empty `checklists/`):

```sh
steplock init --global
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Rules:
- A project checklist with the same directory name replaces the global one. An empty `.steplock/checklists/<name>/` directory turns that global checklist off for the project.
- Session state and the audit log for global checklists are written to the global directory, not to the project.

Run `steplock init --global` to create the directory with a sample checklist. Register the hook once in each agent's user-level settings so it runs in every project: `~/.claude/settings.json` for Claude Code, `~/.hermes/config.yaml` for Hermes Agent (see [Installation](Installation.md#hermes-agent)). Every agent reads the same global directory, so one checklist gates all of them.
Run `steplock init --global` to create the directory with an empty `checklists/`. Register the hook once in each agent's user-level settings so it runs in every project: `~/.claude/settings.json` for Claude Code, `~/.hermes/config.yaml` for Hermes Agent (see [Installation](Installation.md#hermes-agent)). Every agent reads the same global directory, so one checklist gates all of them.

Some agents give hooks a sandboxed `HOME`. Hermes Agent does this in containers and with `TERMINAL_HOME_MODE=profile`, and exports the real home as `HERMES_REAL_HOME`. steplock uses that real home, so the global directory stays the same. To pin it explicitly, set `STEPLOCK_GLOBAL_DIR`.

Expand Down
24 changes: 3 additions & 21 deletions core/src/bin/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ struct Cli {
/// `steplock` subcommands.
#[derive(Debug, Subcommand)]
enum CliCommand {
/// Create .steplock/checklists/ with a sample checklist in the current directory
/// Create an empty .steplock/checklists/ in the current directory
Init {
/// Create checklists/ in the global steplock directory instead
#[arg(long)]
Expand Down Expand Up @@ -168,21 +168,12 @@ fn run_hook() {
}
}

const SAMPLE_CONFIG: &str = r#"on_event = "tool:before"
on_tool = "bash"
match_input = "input.command_words.exists(x, x == 'git') && input.command_words.exists(x, x == 'push')"
reset = "session"
"#;

const SAMPLE_FLOW: &str = "stateDiagram-v2\n [*] --> tests_pass\n tests_pass --> reviewed\n reviewed --> [*]\n tests_pass : Tests pass locally\n reviewed : Code reviewed\n";

/// Create `.steplock/checklists/` and a `.steplock/.gitignore` in `dir`.
/// Also writes a ready-to-use sample checklist so `git push` is blocked immediately.
fn run_init(dir: &Path) -> io::Result<()> {
init_steplock_dir(&dir.join(".steplock"), true)
}

/// Create `checklists/` with a sample checklist in `steplock_dir`.
/// Create an empty `checklists/` in `steplock_dir`. No checklist is written.
/// With `gitignore`, also writes a `.gitignore` for session state and the audit log.
fn init_steplock_dir(steplock_dir: &Path, gitignore: bool) -> io::Result<()> {
let checklists_dir = steplock_dir.join("checklists");
Expand All @@ -194,17 +185,8 @@ fn init_steplock_dir(steplock_dir: &Path, gitignore: bool) -> io::Result<()> {
if gitignore {
fs::write(steplock_dir.join(".gitignore"), "sessions/\naudit.log\n")?;
}
let sample_dir = checklists_dir.join("example-gate");
fs::create_dir_all(&sample_dir)?;
fs::write(sample_dir.join("config.toml"), SAMPLE_CONFIG)?;
fs::write(sample_dir.join("flow.mmd"), SAMPLE_FLOW)?;
println!("steplock: initialized {}", checklists_dir.display());
println!(
"A sample checklist was written to {}.",
sample_dir.display()
);
println!("It will block `git push` until two quality checks are acknowledged.");
println!("Edit config.toml and flow.mmd to customize it, or add more checklists.");
println!("Add a checklist as checklists/<name>/config.toml and flow.mmd.");
Ok(())
}

Expand Down
44 changes: 4 additions & 40 deletions core/src/bin/main_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,25 +144,14 @@ fn init_creates_checklists_dir_and_gitignore() {
}

#[test]
fn init_scaffolds_sample_checklist() {
let tmp = TempDir::new().unwrap();
run_init(tmp.path()).unwrap();
let sample = tmp.path().join(".steplock/checklists/example-gate");
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("command_words"));
let flow = fs::read_to_string(sample.join("flow.mmd")).unwrap();
assert!(flow.contains("stateDiagram-v2"));
}

#[test]
fn init_sample_checklist_is_valid() {
fn init_writes_no_checklist() {
let tmp = TempDir::new().unwrap();
run_init(tmp.path()).unwrap();
let checklists = tmp.path().join(".steplock/checklists");
assert_eq!(fs::read_dir(&checklists).unwrap().count(), 0);
let stdin = claude_stdin("git push origin main", "s1");
let resp = run_app(stdin.as_bytes(), tmp.path(), None).unwrap();
assert!(matches!(resp, polyhook::HookResponse::BlockResponse(_)));
assert!(!matches!(resp, polyhook::HookResponse::BlockResponse(_)));
}

#[test]
Expand Down Expand Up @@ -477,28 +466,3 @@ 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"
);
}
9 changes: 6 additions & 3 deletions core/tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,12 @@ fn init_global_scaffolds_global_dir() {
let target = global.path().join("steplock");
let output = run_subcommand_with_global(&["init", "--global"], global.path(), &target);
assert!(output.status.success(), "init --global should succeed");
assert!(
target.join("checklists/example-gate/config.toml").exists(),
"sample checklist expected in global dir"
let checklists = target.join("checklists");
assert!(checklists.is_dir(), "checklists/ expected in global dir");
assert_eq!(
fs::read_dir(&checklists).unwrap().count(),
0,
"init must not write a default checklist"
);
assert!(
!target.join(".gitignore").exists(),
Expand Down
Loading