diff --git a/Architecture.md b/Architecture.md index a50967b..6ebca9a 100644 --- a/Architecture.md +++ b/Architecture.md @@ -211,6 +211,16 @@ steplock writes to two channels that don't touch the hook stdin/stdout protocol: When two checklist directories both match the same event, steplock processes them in alphabetical order (sorted by directory name). The first incomplete checklist blocks. Once it reaches `[*]`, the next checklist's first state blocks on the subsequent invocation. +### Global checklists + +steplock evaluates two steplock directories: the project `.steplock/` and the global directory (`$STEPLOCK_GLOBAL_DIR`, else `$XDG_CONFIG_HOME/steplock`, else `~/.config/steplock`). Both use the same layout. + +- **Order** — all project checklists first, then global checklists. The first incomplete match blocks. +- **Override** — a global checklist is skipped when the project has a checklist directory with the same name. This lets a project replace a global gate, or turn it off with an empty directory. +- **State** — each checklist stores sessions and audit events in the directory it came from. Global session state lives in the global directory, so no files are written into projects that have no `.steplock/`. +- **Same directory** — when the global directory resolves to the project `.steplock/` (for example, `STEPLOCK_GLOBAL_DIR=~/.steplock` and the project is `~`), it is evaluated once. +- **`session:stop`** — cleans the session directory in both locations. + ### Idempotent ack If `ack.sh` runs when the session is already complete or `current_state` is null (e.g. agent ran it twice), it exits 0 with a message and makes no writes: diff --git a/CHANGELOG.md b/CHANGELOG.md index bab2b21..fb343fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Global checklists: `$STEPLOCK_GLOBAL_DIR`, `$XDG_CONFIG_HOME/steplock` or `~/.config/steplock` holds checklists that apply to every project; project checklists with the same name override them +- `steplock init --global` and `steplock clean --global`; `steplock validate` also checks global checklists +- `run_with_global` and `global_steplock_dir` library API - `steplock init` command creates `.steplock/checklists/` and `.gitignore` skeleton - `session:stop` event cleans up the session directory so the checklist resets - `allow_preview_request` config option generates a `preview.sh` showing checklist progress diff --git a/Installation.md b/Installation.md index f5157ba..861b55b 100644 --- a/Installation.md +++ b/Installation.md @@ -149,6 +149,8 @@ In your project's `.claude/settings.json`: } ``` +To gate every project, put the same block in `~/.claude/settings.json` and add your checklists to the global steplock directory (see [Global checklists](#global-checklists)). + ### Cursor / Windsurf / Cline / Amp Follow your tool's hook registration docs and point the hook command at `steplock`. polyhook normalises the event format — no per-tool changes needed. @@ -174,11 +176,25 @@ cat .steplock/audit.log --- +## Global checklists + +Global checklists apply to every project. Create the global steplock directory with a sample checklist: + +```sh +steplock init --global +# steplock: initialized /Users/you/.config/steplock/checklists +``` + +The directory is `$STEPLOCK_GLOBAL_DIR`, else `$XDG_CONFIG_HOME/steplock`, else `~/.config/steplock`. Project checklists run first. A project checklist with the same name replaces the global one. Set `STEPLOCK_GLOBAL_DIR=""` to turn global checklists off. + +--- + ## Other commands ```sh -steplock validate # check every config.toml / flow.mmd under .steplock/checklists/ for errors -steplock clean # remove all session state under .steplock/sessions/ (checklists restart fresh) +steplock validate # check every config.toml / flow.mmd in .steplock/checklists/ and the global checklists/ +steplock clean # remove all session state under .steplock/sessions/ (checklists restart fresh) +steplock clean --global # remove all session state in the global steplock directory ``` --- diff --git a/README.md b/README.md index 71b28a2..f337d04 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,34 @@ stateDiagram-v2 See [`examples/git-push-quality-gate/`](examples/git-push-quality-gate/) for a complete working example. +### Global checklists + +A global checklist applies to every project, including projects with no `.steplock/`. Use it for gates you want everywhere, such as a check before every `git push`. + +Global checklists live in the global steplock directory, which uses the same layout as `.steplock/`: + +``` +~/.config/steplock/ +└── checklists/ + └── git-push-quality-gate/ + ├── config.toml + └── flow.mmd +``` + +steplock finds the global directory in this order: + +1. `$STEPLOCK_GLOBAL_DIR`. Set it to an empty string to turn global checklists off. +2. `$XDG_CONFIG_HOME/steplock`, when `XDG_CONFIG_HOME` is an absolute path. +3. `~/.config/steplock`, where `~` is your home directory (`%USERPROFILE%` on Windows). + +Rules: + +- Project checklists run first. Global checklists run after them, in alphabetical order. +- A project checklist with the same directory name replaces the global one. An empty `.steplock/checklists//` 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 your user-level tool settings (for Claude Code, `~/.claude/settings.json`) so it runs in every project. + --- ## Editor support diff --git a/core/Cargo.lock b/core/Cargo.lock index 5212033..3791c57 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -161,6 +161,27 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -372,6 +393,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -445,6 +475,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -626,6 +662,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.19", +] + [[package]] name = "regex" version = "1.12.3" @@ -696,6 +742,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schemars" version = "0.8.22" @@ -835,8 +890,10 @@ version = "0.1.0" dependencies = [ "cel-interpreter", "chrono", + "dirs", "polyhook", "proptest", + "same-file", "serde", "serde_json", "tempfile", @@ -1175,6 +1232,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/core/Cargo.toml b/core/Cargo.toml index 1fe8027..50c21ee 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -82,11 +82,14 @@ unwrap_used = "deny" wildcard_imports = "deny" enum_glob_use = "deny" single_match_else = "deny" +manual_let_else = "deny" [dependencies] cel-interpreter = "0.10" chrono = { version = "0.4", features = ["serde"] } +dirs = "6" polyhook = "0.1.5" +same-file = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/core/src/bin/main.rs b/core/src/bin/main.rs index 97f54a2..42adb8a 100644 --- a/core/src/bin/main.rs +++ b/core/src/bin/main.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use std::process; use polyhook::parse; -use steplock::{run, HookEvent, HookResponse}; +use steplock::{global_steplock_dir, run_with_global, HookEvent, HookResponse}; fn main() { let args: Vec = env::args().skip(1).collect(); @@ -26,10 +26,16 @@ fn main() { process::exit(1); } } + [cmd, flag] if cmd == "init" && flag == "--global" => { + if let Err(e) = init_steplock_dir(&require_global_dir(), false) { + eprintln!("steplock: init failed: {e}"); + process::exit(1); + } + } [cmd] if cmd == "validate" => { let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let root = find_repo_root_from(&dir).unwrap_or(dir); - match run_validate(&root) { + match run_validate(&root, global_steplock_dir().as_deref()) { Ok(true) => {} Ok(false) => process::exit(1), Err(e) => { @@ -44,6 +50,12 @@ fn main() { process::exit(1); } } + [cmd, flag] if cmd == "clean" && flag == "--global" => { + if let Err(e) = clean_sessions(&require_global_dir()) { + eprintln!("steplock: clean failed: {e}"); + process::exit(1); + } + } [] => run_hook(), _ => { eprintln!("steplock: unknown arguments"); @@ -62,45 +74,82 @@ Stateful quality gate for AI coding agents. USAGE: steplock Read hook event from stdin and respond (used by polyhook) steplock init Create .steplock/checklists/ in the current directory - steplock validate Check all checklist configs for errors + steplock init --global Create checklists/ in the global steplock directory + steplock validate Check all project and global checklist configs for errors steplock clean Remove all session state (forces checklists to restart) + steplock clean --global + Remove all session state in the global steplock directory steplock --version Print version CHECKLIST FILES: .steplock/checklists//config.toml Gate trigger and reset configuration .steplock/checklists//flow.mmd Mermaid stateDiagram-v2 checklist flow +GLOBAL CHECKLISTS: + Checklists in /checklists// apply to every project. They run after + the project checklists. A project checklist with the same name replaces the global one. + is $STEPLOCK_GLOBAL_DIR, else $XDG_CONFIG_HOME/steplock, else + ~/.config/steplock. Set STEPLOCK_GLOBAL_DIR=\"\" to turn global checklists off. + For more information: https://github.com/polyhook/steplock", env!("CARGO_PKG_VERSION") ); } -/// Validate all checklists in `.steplock/checklists/`. Returns `Ok(true)` if all valid, -/// `Ok(false)` if any checklist failed validation (errors already printed), or `Err` on I/O. -fn run_validate(repo_root: &Path) -> io::Result { - let checklists_dir = repo_root.join(".steplock").join("checklists"); +/// Validate all checklists in `.steplock/checklists/` and in the global steplock directory. +/// Returns `Ok(true)` if all valid, `Ok(false)` if any checklist failed validation (errors +/// already printed), or `Err` on I/O. +fn run_validate(repo_root: &Path, global_dir: Option<&Path>) -> io::Result { + let project_ok = validate_dir(&repo_root.join(".steplock").join("checklists"), "")?; + let global_ok = match global_dir { + Some(global) => validate_dir(&global.join("checklists"), "global")?, + None => true, + }; + Ok(project_ok && global_ok) +} + +/// Validate one `checklists/` directory. `scope` names it in messages (`""` or `"global"`). +fn validate_dir(checklists_dir: &Path, scope: &str) -> io::Result { + let shown = checklists_dir.display(); + let (words, label_prefix) = if scope.is_empty() { + (String::new(), String::new()) + } else { + (format!("{scope} "), format!("{scope}:")) + }; if !checklists_dir.exists() { - println!("steplock: no .steplock/checklists/ found"); + println!("steplock: no {words}checklists found at {shown}"); return Ok(true); } - let errors = steplock::validate_checklists(&checklists_dir); + let errors = steplock::validate_checklists(checklists_dir); if errors.is_empty() { - println!("steplock: all checklists valid"); + println!("steplock: all {words}checklists valid ({shown})"); Ok(true) } else { for (label, err) in &errors { - eprintln!("steplock: [{label}] error: {err}"); + eprintln!("steplock: [{label_prefix}{label}] error: {err}"); } Ok(false) } } +/// Global steplock directory, or exit with an error when it is disabled or unknown. +fn require_global_dir() -> PathBuf { + global_steplock_dir().unwrap_or_else(|| { + eprintln!( + "steplock: no global steplock directory \ + (set STEPLOCK_GLOBAL_DIR, XDG_CONFIG_HOME or HOME)" + ); + process::exit(1); + }) +} + fn run_hook() { let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let repo_root = find_repo_root_from(&cwd).unwrap_or(cwd); - let response = match run_app(io::stdin(), &repo_root) { + let global_dir = global_steplock_dir(); + let response = match run_app(io::stdin(), &repo_root, global_dir.as_deref()) { Ok(r) => r, Err(e) => { eprintln!("{e}"); @@ -125,22 +174,30 @@ const SAMPLE_FLOW: &str = "stateDiagram-v2\n [*] --> tests_pass\n tests_pa /// 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<()> { - let checklists_dir = dir.join(".steplock").join("checklists"); + init_steplock_dir(&dir.join(".steplock"), true) +} + +/// Create `checklists/` with a sample checklist in `steplock_dir`. +/// 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"); if checklists_dir.exists() { - println!("steplock: .steplock/checklists/ already exists"); + println!("steplock: {} already exists", checklists_dir.display()); return Ok(()); } fs::create_dir_all(&checklists_dir)?; - fs::write( - dir.join(".steplock").join(".gitignore"), - "sessions/\naudit.log\n", - )?; + 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 .steplock/checklists/"); - println!("A sample checklist was written to .steplock/checklists/example-gate/."); + 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."); Ok(()) @@ -156,7 +213,11 @@ fn run_clean(dir: &Path) -> io::Result<()> { println!("steplock: no .steplock/ directory found — nothing to clean"); return Ok(()); }; - let steplock_dir = root.join(".steplock"); + clean_sessions(&root.join(".steplock")) +} + +/// Remove every session directory and the fallback id under `/sessions/`. +fn clean_sessions(steplock_dir: &Path) -> io::Result<()> { let sessions_dir = steplock_dir.join("sessions"); if !sessions_dir.exists() { println!("steplock: no sessions to clean"); @@ -182,7 +243,11 @@ fn run_clean(dir: &Path) -> io::Result<()> { /// Parse the hook event from `reader`, run the gate, and return the polyhook response. /// Returns `Err(message)` when input is unreadable or the gate engine fails. -fn run_app(mut reader: impl Read, repo_root: &Path) -> Result { +fn run_app( + mut reader: impl Read, + repo_root: &Path, + global_dir: Option<&Path>, +) -> Result { let mut bytes = Vec::new(); reader .read_to_end(&mut bytes) @@ -193,7 +258,7 @@ fn run_app(mut reader: impl Read, repo_root: &Path) -> Result Ok(polyhook::HookResponse::block(&message)), Ok(_) => Ok(polyhook::HookResponse::approve()), Err(e) => Err(format!("steplock: error: {e}")), @@ -287,7 +352,7 @@ reset = "session" let tmp = TempDir::new().unwrap(); setup_checklist(tmp.path()); let stdin = claude_stdin("ls -la", "s1"); - let resp = run_app(stdin.as_bytes(), tmp.path()).unwrap(); + let resp = run_app(stdin.as_bytes(), tmp.path(), None).unwrap(); assert!(matches!(resp, polyhook::HookResponse::ApproveResponse(_))); } @@ -296,14 +361,14 @@ reset = "session" let tmp = TempDir::new().unwrap(); setup_checklist(tmp.path()); let stdin = claude_stdin("git push origin main", "s1"); - let resp = run_app(stdin.as_bytes(), tmp.path()).unwrap(); + let resp = run_app(stdin.as_bytes(), tmp.path(), None).unwrap(); assert!(matches!(resp, polyhook::HookResponse::BlockResponse(_))); } #[test] fn run_app_error_on_invalid_input() { let tmp = TempDir::new().unwrap(); - let err = run_app(b"not valid json".as_ref(), tmp.path()); + let err = run_app(b"not valid json".as_ref(), tmp.path(), None); assert!(err.is_err()); assert!(err .unwrap_err() @@ -330,7 +395,7 @@ reset = "session" ) .unwrap(); let stdin = claude_stdin("anything", "s1"); - let err = run_app(stdin.as_bytes(), tmp.path()); + let err = run_app(stdin.as_bytes(), tmp.path(), None); assert!(err.is_err()); assert!(err.unwrap_err().contains("steplock: error:")); } @@ -388,7 +453,7 @@ reset = "session" let tmp = TempDir::new().unwrap(); run_init(tmp.path()).unwrap(); let stdin = claude_stdin("git push origin main", "s1"); - let resp = run_app(stdin.as_bytes(), tmp.path()).unwrap(); + let resp = run_app(stdin.as_bytes(), tmp.path(), None).unwrap(); assert!(matches!(resp, polyhook::HookResponse::BlockResponse(_))); } @@ -444,21 +509,21 @@ reset = "session" #[test] fn validate_returns_true_when_no_checklists_dir() { let tmp = TempDir::new().unwrap(); - assert!(run_validate(tmp.path()).unwrap()); + assert!(run_validate(tmp.path(), None).unwrap()); } #[test] fn validate_returns_true_when_checklists_empty() { let tmp = TempDir::new().unwrap(); fs::create_dir_all(tmp.path().join(".steplock/checklists")).unwrap(); - assert!(run_validate(tmp.path()).unwrap()); + assert!(run_validate(tmp.path(), None).unwrap()); } #[test] fn validate_returns_true_for_valid_checklist() { let tmp = TempDir::new().unwrap(); setup_checklist(tmp.path()); - assert!(run_validate(tmp.path()).unwrap()); + assert!(run_validate(tmp.path(), None).unwrap()); } #[test] @@ -471,7 +536,7 @@ reset = "session" "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", ) .unwrap(); - assert!(!run_validate(tmp.path()).unwrap()); + assert!(!run_validate(tmp.path(), None).unwrap()); } #[test] @@ -484,7 +549,7 @@ reset = "session" "on_event = \"tool:before\"\nreset = \"session\"\n", ) .unwrap(); - assert!(!run_validate(tmp.path()).unwrap()); + assert!(!run_validate(tmp.path(), None).unwrap()); } #[test] @@ -498,7 +563,7 @@ reset = "session" "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", ) .unwrap(); - assert!(!run_validate(tmp.path()).unwrap()); + assert!(!run_validate(tmp.path(), None).unwrap()); } #[test] @@ -512,7 +577,7 @@ reset = "session" ) .unwrap(); fs::write(cl_dir.join("flow.mmd"), "stateDiagram-v2\n a --> b\n").unwrap(); - assert!(!run_validate(tmp.path()).unwrap()); + assert!(!run_validate(tmp.path(), None).unwrap()); } #[test] @@ -527,6 +592,6 @@ reset = "session" "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", ) .unwrap(); - assert!(!run_validate(tmp.path()).unwrap()); + assert!(!run_validate(tmp.path(), None).unwrap()); } } diff --git a/core/src/catalog.rs b/core/src/catalog.rs new file mode 100644 index 0000000..812ad6b --- /dev/null +++ b/core/src/catalog.rs @@ -0,0 +1,20 @@ +//! Checklist catalog: discovers the checklists defined in a steplock directory. +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::Result; + +/// Checklist directories under `/checklists/`, sorted by name. +/// Returns an empty list when the directory does not exist. +pub(crate) fn checklist_dirs(steplock_dir: &Path) -> Result> { + let checklists_dir = steplock_dir.join("checklists"); + if !checklists_dir.exists() { + return Ok(vec![]); + } + let mut entries: Vec = fs::read_dir(&checklists_dir)? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_dir()) + .collect(); + entries.sort(); // deterministic declaration order + Ok(entries) +} diff --git a/core/src/gate.rs b/core/src/gate.rs new file mode 100644 index 0000000..93f5a73 --- /dev/null +++ b/core/src/gate.rs @@ -0,0 +1,249 @@ +//! Checklist gate: decides whether one checklist blocks a hook event. +use std::fmt::Write as _; +use std::fs; +use std::path::Path; + +use crate::audit; +use crate::cel_eval; +use crate::config::{parse_config, Reset}; +use crate::error::{Result, SteplockError}; +use crate::flow::{parse_mmd, FlowGraph}; +use crate::scripts; +use crate::state::{init_state, load_state, save_state, HookEvent, HookResponse, SessionState}; + +/// Evaluate one checklist. Returns `Some(Block)` when it blocks the event, `None` otherwise. +pub(crate) fn evaluate_checklist( + event: &HookEvent, + steplock_dir: &Path, + checklist_dir: &Path, +) -> Result> { + let checklist_name = checklist_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_owned(); + + let config_path = checklist_dir.join("config.toml"); + let flow_path = checklist_dir.join("flow.mmd"); + + if !config_path.exists() || !flow_path.exists() { + return Ok(None); + } + + let config_str = fs::read_to_string(&config_path)?; + let config = parse_config(config_path.to_str().unwrap_or("config.toml"), &config_str)?; + + if config.on_event != event.event { + return Ok(None); + } + if !config.on_tool.is_empty() && config.on_tool != event.tool { + return Ok(None); + } + + if !cel_eval::matches_event(event, &config.match_input)? { + return Ok(None); + } + + let flow_str = fs::read_to_string(&flow_path)?; + let flow = parse_mmd(flow_path.to_str().unwrap_or("flow.mmd"), &flow_str)?; + + let initial_state = flow.initial.first().ok_or_else(|| SteplockError::Mermaid { + path: flow_path.to_str().unwrap_or("flow.mmd").to_owned(), + message: "no initial state found".to_owned(), + })?; + + match config.reset { + Reset::Always => Ok(Some(block_reset_always( + steplock_dir, + &checklist_name, + initial_state, + &flow, + ))), + Reset::Session => block_reset_session( + event, + steplock_dir, + &checklist_name, + initial_state, + &flow, + config.allow_preview_request, + ), + } +} + +fn block_reset_always( + steplock_dir: &Path, + checklist_name: &str, + initial_state: &str, + flow: &FlowGraph, +) -> HookResponse { + let transitions: Vec = flow + .transitions + .get(initial_state) + .cloned() + .unwrap_or_default(); + let next_state = transitions + .first() + .cloned() + .filter(|_| transitions.len() == 1); + let state = SessionState { + checklist: checklist_name.to_owned(), + current_state: initial_state.to_owned(), + next_state, + transitions, + visited: vec![], + }; + audit::append( + steplock_dir, + "block", + checklist_name, + initial_state, + "always", + ); + let message = build_block_message(&state, flow, None); + eprintln!("steplock: block [{checklist_name}] state={initial_state}"); + HookResponse::Block { message } +} + +fn block_reset_session( + event: &HookEvent, + steplock_dir: &Path, + checklist_name: &str, + initial_state: &str, + flow: &FlowGraph, + allow_preview: bool, +) -> Result> { + let scope_key = get_scope_key(event, steplock_dir)?; + let session_dir = steplock_dir + .join("sessions") + .join(&scope_key) + .join(checklist_name); + fs::create_dir_all(&session_dir)?; + + let state_path = session_dir.join("state.json"); + let mut state = if state_path.exists() { + load_state(&state_path)? + } else { + init_state(checklist_name, initial_state) + }; + + // Checklist complete — approve this attempt and reset state so the + // next invocation starts the checklist fresh. + if state.is_complete() { + audit::append(steplock_dir, "complete", checklist_name, "[*]", &scope_key); + save_state(&state_path, &init_state(checklist_name, initial_state))?; + return Ok(None); + } + + // Raw transitions including [*] — stored in state.json for ack.sh validation. + let raw_transitions: Vec = flow + .transitions + .get(&state.current_state) + .cloned() + .unwrap_or_default(); + + if raw_transitions.is_empty() { + // State unknown in flow — skip silently (flow changed mid-session). + return Ok(None); + } + + // next_state: auto-advance when only one transition (may be "[*]"). + state.next_state = raw_transitions + .first() + .cloned() + .filter(|_| raw_transitions.len() == 1); + state.transitions = raw_transitions; + + save_state(&state_path, &state)?; + scripts::ensure_ack_sh(&session_dir)?; + if allow_preview { + scripts::ensure_preview_sh(&session_dir, checklist_name, flow)?; + } + + audit::append( + steplock_dir, + "block", + checklist_name, + &state.current_state, + &scope_key, + ); + eprintln!( + "steplock: block [{}] state={} session={}", + checklist_name, state.current_state, scope_key + ); + + let message = build_block_message(&state, flow, Some(&session_dir)); + Ok(Some(HookResponse::Block { message })) +} + +fn get_scope_key(event: &HookEvent, steplock_dir: &Path) -> Result { + if !event.session_id.is_empty() { + return Ok(event.session_id.clone()); + } + let fallback_path = steplock_dir.join("sessions").join("fallback-id"); + if fallback_path.exists() { + let id = fs::read_to_string(&fallback_path)?; + return Ok(id.trim().to_owned()); + } + let id = uuid::Uuid::new_v4().to_string(); + fs::create_dir_all(steplock_dir.join("sessions"))?; + fs::write(&fallback_path, &id)?; + Ok(id) +} + +/// `session_dir` is `None` for `reset=always` checklists (no persistent ack.sh). +fn build_block_message( + state: &SessionState, + flow: &FlowGraph, + session_dir: Option<&Path>, +) -> String { + let label = flow + .labels + .get(&state.current_state) + .map_or(state.current_state.as_str(), String::as_str); + + let checklist = &state.checklist; + let step = state.visited.len() + 1; + let total = flow.order.len(); + let mut msg = format!("[{checklist}: {step}/{total}] {label}"); + msg.push_str("\n\n"); + + let visible: Vec<&String> = state + .transitions + .iter() + .filter(|s| s.as_str() != "[*]") + .collect(); + + if let Some(dir) = session_dir { + let ack = dir.join("ack.sh"); + let ack_path = ack.display(); + if visible.len() <= 1 { + let _ = write!( + msg, + "When finished, run: sh {ack_path}\nThen retry your original command." + ); + } else { + msg.push_str("When finished, run one of:\n"); + for next in &visible { + let next_label = flow.labels.get(*next).map_or(next.as_str(), String::as_str); + let _ = writeln!(msg, " sh {ack_path} {next} — {next_label}"); + } + msg.push_str("Then retry your original command."); + } + + if state.visited.is_empty() { + let preview = dir.join("preview.sh"); + if preview.exists() { + let _ = write!( + msg, + "\n(Tip: run sh {} to see all items first.)", + preview.display() + ); + } + } + } else { + // reset=always: no persistent ack.sh — agent confirms in conversation then retries. + msg.push_str("When done, retry your original command."); + } + + msg +} diff --git a/core/src/global_config.rs b/core/src/global_config.rs new file mode 100644 index 0000000..ccc4aff --- /dev/null +++ b/core/src/global_config.rs @@ -0,0 +1,132 @@ +//! Location of the global steplock directory shared by every project. +//! +//! The global directory has the same layout as a project `.steplock/`: +//! `checklists//{config.toml,flow.mmd}`, `sessions/` and `audit.log`. +use std::env; +use std::ffi::OsString; +use std::path::PathBuf; + +/// Environment variable that overrides the global steplock directory. +/// Set it to an empty string to turn global checklists off. +pub const GLOBAL_DIR_ENV: &str = "STEPLOCK_GLOBAL_DIR"; + +/// Resolve the global steplock directory from the process environment. +/// +/// Lookup order: +/// 1. `$STEPLOCK_GLOBAL_DIR` — used as-is; an empty value disables global checklists. +/// 2. `$XDG_CONFIG_HOME/steplock` — when `XDG_CONFIG_HOME` is set to an absolute path. +/// 3. `/.config/steplock`, where `` comes from [`dirs::home_dir`]. +/// +/// Returns `None` when global checklists are disabled or no home directory is known. +/// The directory is not required to exist. +#[must_use] +pub fn global_steplock_dir() -> Option { + resolve_global_dir(|key| env::var_os(key), dirs::home_dir()) +} + +/// Resolve the global steplock directory with `var` as the environment lookup and `home` +/// as the user's home directory. +fn resolve_global_dir( + var: impl Fn(&str) -> Option, + home: Option, +) -> Option { + if let Some(dir) = var(GLOBAL_DIR_ENV) { + return if dir.is_empty() { + None + } else { + Some(PathBuf::from(dir)) + }; + } + if let Some(xdg) = var("XDG_CONFIG_HOME").map(PathBuf::from) { + if xdg.is_absolute() { + return Some(xdg.join("steplock")); + } + } + home.map(|home| home.join(".config").join("steplock")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// An absolute path on the current platform (`/name` or `C:\name`). + fn abs(name: &str) -> PathBuf { + let root = if cfg!(windows) { "C:\\" } else { "/" }; + PathBuf::from(root).join(name) + } + + fn lookup(vars: &[(&str, OsString)]) -> impl Fn(&str) -> Option { + let map: HashMap = vars + .iter() + .map(|(k, v)| ((*k).to_owned(), v.clone())) + .collect(); + move |key| map.get(key).cloned() + } + + fn home_config(home: &str) -> PathBuf { + abs(home).join(".config").join("steplock") + } + + #[test] + fn env_override_wins() { + let dir = resolve_global_dir( + lookup(&[ + (GLOBAL_DIR_ENV, abs("custom").into()), + ("XDG_CONFIG_HOME", abs("xdg").into()), + ]), + Some(abs("home")), + ); + assert_eq!( + dir, + Some(abs("custom")), + "STEPLOCK_GLOBAL_DIR must take precedence" + ); + } + + #[test] + fn empty_env_override_disables_global() { + let dir = resolve_global_dir( + lookup(&[(GLOBAL_DIR_ENV, OsString::new())]), + Some(abs("home")), + ); + assert_eq!(dir, None, "empty STEPLOCK_GLOBAL_DIR must disable global"); + } + + #[test] + fn uses_xdg_config_home() { + let dir = resolve_global_dir( + lookup(&[("XDG_CONFIG_HOME", abs("xdg").into())]), + Some(abs("home")), + ); + assert_eq!(dir, Some(abs("xdg").join("steplock")), "XDG path expected"); + } + + #[test] + fn ignores_relative_xdg_config_home() { + let dir = resolve_global_dir( + lookup(&[("XDG_CONFIG_HOME", "relative".into())]), + Some(abs("home")), + ); + assert_eq!( + dir, + Some(home_config("home")), + "relative XDG_CONFIG_HOME must fall back to the home directory" + ); + } + + #[test] + fn falls_back_to_home_dir() { + let dir = resolve_global_dir(lookup(&[]), Some(abs("home"))); + assert_eq!(dir, Some(home_config("home")), "home fallback expected"); + } + + #[test] + fn none_without_home() { + assert_eq!( + resolve_global_dir(lookup(&[]), None), + None, + "no env and no home means no global dir" + ); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index acf7c58..7921e3f 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -8,6 +8,8 @@ /// Audit log utilities. pub(crate) mod audit; +/// Checklist catalog: discovers checklist directories. +pub(crate) mod catalog; /// CEL expression evaluator for `match_input` conditions. pub(crate) mod cel_eval; /// Checklist configuration types and TOML parser. @@ -16,6 +18,10 @@ pub(crate) mod config; pub mod error; /// Mermaid `stateDiagram-v2` parser. pub mod flow; +/// Checklist gate: evaluates one checklist against a hook event. +pub(crate) mod gate; +/// Location of the global steplock directory shared by every project. +pub mod global_config; /// Gate runner — entry point for polyhook integration. pub mod run; /// Ack and preview script generation. @@ -26,6 +32,7 @@ pub mod state; pub mod validate; pub use error::{Result, SteplockError}; -pub use run::run; +pub use global_config::global_steplock_dir; +pub use run::{run, run_with_global}; pub use state::{HookEvent, HookResponse, SessionState}; pub use validate::validate_checklists; diff --git a/core/src/run.rs b/core/src/run.rs index 5e31f8d..5c7382d 100644 --- a/core/src/run.rs +++ b/core/src/run.rs @@ -1,107 +1,81 @@ //! Core gate logic: evaluates checklists against incoming hook events. -use std::fmt::Write as _; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; -use crate::audit; -use crate::cel_eval; -use crate::config::{parse_config, Reset}; -use crate::error::{Result, SteplockError}; -use crate::flow::{parse_mmd, FlowGraph}; -use crate::scripts; -use crate::state::{init_state, load_state, save_state, HookEvent, HookResponse, SessionState}; +use crate::catalog::checklist_dirs; +use crate::error::Result; +use crate::gate::evaluate_checklist; +use crate::state::{HookEvent, HookResponse}; -/// Run the full steplock gate logic. +/// Run the full steplock gate logic against the project checklists only. /// /// `repo_root` — the directory that contains `.steplock/`. /// Returns `HookResponse::Approve` if no checklist blocks, or /// `HookResponse::Block { message }` with the gate message. /// +/// Equivalent to [`run_with_global`] with no global steplock directory. +/// /// # Errors /// /// Returns `Err` on I/O failures (reading checklist files, writing state) or on invalid /// checklist configuration (bad TOML, invalid Mermaid, invalid CEL expression). +pub fn run(event: &HookEvent, repo_root: &Path) -> Result { + run_with_global(event, repo_root, None) +} + +/// Run the gate logic against the project checklists, then the global checklists. /// -/// # Panics +/// `repo_root` — the directory that contains the project `.steplock/`. +/// `global_dir` — a steplock directory shared by every project (see +/// [`crate::global_config::global_steplock_dir`]). It has the same layout as `.steplock/`: +/// `checklists/`, `sessions/` and `audit.log`. /// -/// Panics if `parse_mmd` returns a graph with no initial state, which it guarantees cannot -/// happen. -pub fn run(event: &HookEvent, repo_root: &Path) -> Result { - let steplock_dir = repo_root.join(".steplock"); +/// Project checklists are evaluated first. A global checklist is skipped when the project +/// has a checklist with the same name, so a project can override or disable it. Session +/// state for a global checklist lives in `global_dir`, not in the project. +/// +/// # Errors +/// +/// Returns `Err` on I/O failures (reading checklist files, writing state) or on invalid +/// checklist configuration (bad TOML, invalid Mermaid, invalid CEL expression). +pub fn run_with_global( + event: &HookEvent, + repo_root: &Path, + global_dir: Option<&Path>, +) -> Result { + let project_dir = repo_root.join(".steplock"); + let global_dir = + global_dir.filter(|g| !same_file::is_same_file(g, &project_dir).unwrap_or(false)); if event.event == "session:stop" { - cleanup_session(&steplock_dir, &event.session_id)?; - return Ok(HookResponse::Approve); - } - - let checklists_dir = steplock_dir.join("checklists"); - - if !checklists_dir.exists() { + cleanup_session(&project_dir, &event.session_id)?; + if let Some(global) = global_dir { + cleanup_session(global, &event.session_id)?; + } return Ok(HookResponse::Approve); } - let mut entries: Vec = fs::read_dir(&checklists_dir)? - .filter_map(|e| e.ok().map(|e| e.path())) - .filter(|p| p.is_dir()) - .collect(); - entries.sort(); // deterministic declaration order - - for checklist_dir in entries { - let checklist_name = checklist_dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_owned(); - - let config_path = checklist_dir.join("config.toml"); - let flow_path = checklist_dir.join("flow.mmd"); - - if !config_path.exists() || !flow_path.exists() { - continue; - } - - let config_str = fs::read_to_string(&config_path)?; - let config = parse_config(config_path.to_str().unwrap_or("config.toml"), &config_str)?; - - if config.on_event != event.event { - continue; - } - if !config.on_tool.is_empty() && config.on_tool != event.tool { - continue; - } - - if !cel_eval::matches_event(event, &config.match_input)? { - continue; + let project_checklists = checklist_dirs(&project_dir)?; + for checklist_dir in &project_checklists { + if let Some(resp) = evaluate_checklist(event, &project_dir, checklist_dir)? { + return Ok(resp); } + } - let flow_str = fs::read_to_string(&flow_path)?; - let flow = parse_mmd(flow_path.to_str().unwrap_or("flow.mmd"), &flow_str)?; - - let initial_state = flow.initial.first().ok_or_else(|| SteplockError::Mermaid { - path: flow_path.to_str().unwrap_or("flow.mmd").to_owned(), - message: "no initial state found".to_owned(), - })?; - - match config.reset { - Reset::Always => { - return Ok(block_reset_always( - &steplock_dir, - &checklist_name, - initial_state, - &flow, - )); + if let Some(global) = global_dir { + let project_names: Vec<_> = project_checklists + .iter() + .filter_map(|p| p.file_name()) + .collect(); + for checklist_dir in checklist_dirs(global)? { + let shadowed = checklist_dir + .file_name() + .is_some_and(|n| project_names.contains(&n)); + if shadowed { + continue; } - Reset::Session => { - if let Some(resp) = block_reset_session( - event, - &steplock_dir, - &checklist_name, - initial_state, - &flow, - config.allow_preview_request, - )? { - return Ok(resp); - } + if let Some(resp) = evaluate_checklist(event, global, &checklist_dir)? { + return Ok(resp); } } } @@ -109,111 +83,6 @@ pub fn run(event: &HookEvent, repo_root: &Path) -> Result { Ok(HookResponse::Approve) } -fn block_reset_always( - steplock_dir: &Path, - checklist_name: &str, - initial_state: &str, - flow: &FlowGraph, -) -> HookResponse { - let transitions: Vec = flow - .transitions - .get(initial_state) - .cloned() - .unwrap_or_default(); - let next_state = transitions - .first() - .cloned() - .filter(|_| transitions.len() == 1); - let state = SessionState { - checklist: checklist_name.to_owned(), - current_state: initial_state.to_owned(), - next_state, - transitions, - visited: vec![], - }; - audit::append( - steplock_dir, - "block", - checklist_name, - initial_state, - "always", - ); - let message = build_block_message(&state, flow, None); - eprintln!("steplock: block [{checklist_name}] state={initial_state}"); - HookResponse::Block { message } -} - -fn block_reset_session( - event: &HookEvent, - steplock_dir: &Path, - checklist_name: &str, - initial_state: &str, - flow: &FlowGraph, - allow_preview: bool, -) -> Result> { - let scope_key = get_scope_key(event, steplock_dir)?; - let session_dir = steplock_dir - .join("sessions") - .join(&scope_key) - .join(checklist_name); - fs::create_dir_all(&session_dir)?; - - let state_path = session_dir.join("state.json"); - let mut state = if state_path.exists() { - load_state(&state_path)? - } else { - init_state(checklist_name, initial_state) - }; - - // Checklist complete — approve this attempt and reset state so the - // next invocation starts the checklist fresh. - if state.is_complete() { - audit::append(steplock_dir, "complete", checklist_name, "[*]", &scope_key); - save_state(&state_path, &init_state(checklist_name, initial_state))?; - return Ok(None); - } - - // Raw transitions including [*] — stored in state.json for ack.sh validation. - let raw_transitions: Vec = flow - .transitions - .get(&state.current_state) - .cloned() - .unwrap_or_default(); - - if raw_transitions.is_empty() { - // State unknown in flow — skip silently (flow changed mid-session). - return Ok(None); - } - - // next_state: auto-advance when only one transition (may be "[*]"). - state.next_state = raw_transitions - .first() - .cloned() - .filter(|_| raw_transitions.len() == 1); - state.transitions = raw_transitions; - - save_state(&state_path, &state)?; - scripts::ensure_ack_sh(&session_dir)?; - if allow_preview { - scripts::ensure_preview_sh(&session_dir, checklist_name, flow)?; - } - - audit::append( - steplock_dir, - "block", - checklist_name, - &state.current_state, - &scope_key, - ); - eprintln!( - "steplock: block [{}] state={} session={}", - checklist_name, state.current_state, scope_key - ); - - let message = build_block_message(&state, flow, Some(&session_dir)); - Ok(Some(HookResponse::Block { message })) -} - fn cleanup_session(steplock_dir: &Path, session_id: &str) -> Result<()> { if !steplock_dir.exists() { return Ok(()); @@ -236,83 +105,11 @@ fn cleanup_session(steplock_dir: &Path, session_id: &str) -> Result<()> { Ok(()) } -fn get_scope_key(event: &HookEvent, steplock_dir: &Path) -> Result { - if !event.session_id.is_empty() { - return Ok(event.session_id.clone()); - } - let fallback_path = steplock_dir.join("sessions").join("fallback-id"); - if fallback_path.exists() { - let id = fs::read_to_string(&fallback_path)?; - return Ok(id.trim().to_owned()); - } - let id = uuid::Uuid::new_v4().to_string(); - fs::create_dir_all(steplock_dir.join("sessions"))?; - fs::write(&fallback_path, &id)?; - Ok(id) -} - -/// `session_dir` is `None` for `reset=always` checklists (no persistent ack.sh). -fn build_block_message( - state: &SessionState, - flow: &FlowGraph, - session_dir: Option<&Path>, -) -> String { - let label = flow - .labels - .get(&state.current_state) - .map_or(state.current_state.as_str(), String::as_str); - - let checklist = &state.checklist; - let step = state.visited.len() + 1; - let total = flow.order.len(); - let mut msg = format!("[{checklist}: {step}/{total}] {label}"); - msg.push_str("\n\n"); - - let visible: Vec<&String> = state - .transitions - .iter() - .filter(|s| s.as_str() != "[*]") - .collect(); - - if let Some(dir) = session_dir { - let ack = dir.join("ack.sh"); - let ack_path = ack.display(); - if visible.len() <= 1 { - let _ = write!( - msg, - "When finished, run: sh {ack_path}\nThen retry your original command." - ); - } else { - msg.push_str("When finished, run one of:\n"); - for next in &visible { - let next_label = flow.labels.get(*next).map_or(next.as_str(), String::as_str); - let _ = writeln!(msg, " sh {ack_path} {next} — {next_label}"); - } - msg.push_str("Then retry your original command."); - } - - if state.visited.is_empty() { - let preview = dir.join("preview.sh"); - if preview.exists() { - let _ = write!( - msg, - "\n(Tip: run sh {} to see all items first.)", - preview.display() - ); - } - } - } else { - // reset=always: no persistent ack.sh — agent confirms in conversation then retries. - msg.push_str("When done, retry your original command."); - } - - msg -} - #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used)] mod tests { use super::*; + use crate::state::{load_state, save_state, SessionState}; use std::collections::HashMap; use tempfile::TempDir; diff --git a/core/tests/cli.rs b/core/tests/cli.rs index 14358dc..15bd627 100644 --- a/core/tests/cli.rs +++ b/core/tests/cli.rs @@ -4,7 +4,7 @@ use std::fmt::Write as FmtWrite; use std::fs; use std::io::Write; use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::{Command, Output, Stdio}; use tempfile::TempDir; const STEPLOCK: &str = env!("CARGO_BIN_EXE_steplock"); @@ -37,8 +37,15 @@ fn checklist(root: &Path, on_event: &str, on_tool: &str, match_input: Option<&st } fn run_steplock(root: &Path, stdin: &str) -> (i32, String, String) { + run_steplock_with_global(root, stdin, "") +} + +/// Run the hook in `dir` with `STEPLOCK_GLOBAL_DIR` set to `global` (`""` disables global +/// checklists) and `stdin` as the hook event. Returns `(exit code, stdout, stderr)`. +fn run_steplock_with_global(dir: &Path, stdin: &str, global: &str) -> (i32, String, String) { let mut child = Command::new(STEPLOCK) - .current_dir(root) + .current_dir(dir) + .env("STEPLOCK_GLOBAL_DIR", global) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -60,6 +67,16 @@ fn run_steplock(root: &Path, stdin: &str) -> (i32, String, String) { ) } +/// Run a `steplock` subcommand with `STEPLOCK_GLOBAL_DIR` set to `global`. +fn run_subcommand_with_global(args: &[&str], dir: &Path, global: &Path) -> Output { + Command::new(STEPLOCK) + .args(args) + .current_dir(dir) + .env("STEPLOCK_GLOBAL_DIR", global) + .output() + .expect("failed to run steplock") +} + #[test] fn version_flag_prints_version() { let output = Command::new(STEPLOCK) @@ -202,23 +219,131 @@ fn hook_finds_steplock_dir_in_parent() { fs::create_dir_all(&subdir).unwrap(); let stdin = hook_event("bash", "git push origin main", "sess1"); - let mut child = Command::new(STEPLOCK) - .current_dir(&subdir) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(stdin.as_bytes()) - .unwrap(); - let output = child.wait_with_output().unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); + let (_code, stdout, _stderr) = run_steplock(&subdir, &stdin); assert!( stdout.to_lowercase().contains("block") || stdout.contains("Did you check"), "should block even from subdirectory; got: {stdout}" ); } + +// ── Global checklists ────────────────────────────────────────────────────── + +/// Write a `git push` checklist named `name` into `steplock_dir/checklists/`. +fn global_checklist(steplock_dir: &Path, name: &str, question: &str) { + let dir = steplock_dir.join("checklists").join(name); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("config.toml"), + "on_event = \"tool:before\"\non_tool = \"bash\"\nmatch_input = \"input.command.contains('git push')\"\n", + ) + .unwrap(); + fs::write( + dir.join("flow.mmd"), + format!("stateDiagram-v2\n [*] --> q\n q --> [*]\n q: {question}\n"), + ) + .unwrap(); +} + +#[test] +fn hook_blocks_with_global_checklist_in_project_without_steplock() { + let project = TempDir::new().unwrap(); + let global = TempDir::new().unwrap(); + global_checklist(global.path(), "push-gate", "Global push question?"); + + let stdin = hook_event("bash", "git push origin main", "sess-g"); + let (code, stdout, _stderr) = + run_steplock_with_global(project.path(), &stdin, global.path().to_str().unwrap()); + assert_eq!(code, 0, "block response exits 0"); + assert!( + stdout.contains("Global push question?"), + "expected global checklist block, got: {stdout}" + ); + assert!( + global + .path() + .join("sessions/sess-g/push-gate/state.json") + .exists(), + "global session state must live in the global dir" + ); + assert!( + !project.path().join(".steplock").exists(), + "project dir must stay untouched" + ); +} + +#[test] +fn hook_ignores_global_checklist_when_disabled() { + let project = TempDir::new().unwrap(); + let stdin = hook_event("bash", "git push origin main", "sess-g"); + let (code, stdout, _stderr) = run_steplock_with_global(project.path(), &stdin, ""); + assert_eq!(code, 0, "approve exits 0"); + assert!( + stdout.trim() == "{}" || stdout.is_empty(), + "expected approve, got: {stdout}" + ); +} + +#[test] +fn init_global_scaffolds_global_dir() { + let global = TempDir::new().unwrap(); + 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" + ); + assert!( + !target.join(".gitignore").exists(), + "global dir is not a repo; no .gitignore" + ); +} + +#[test] +fn init_global_fails_when_disabled() { + let dir = TempDir::new().unwrap(); + let output = run_subcommand_with_global(&["init", "--global"], dir.path(), Path::new("")); + assert_eq!( + output.status.code(), + Some(1), + "disabled global dir must fail" + ); +} + +#[test] +fn validate_reports_invalid_global_checklist() { + let project = TempDir::new().unwrap(); + let global = TempDir::new().unwrap(); + let bad = global.path().join("checklists/bad"); + fs::create_dir_all(&bad).unwrap(); + fs::write(bad.join("config.toml"), "not valid toml").unwrap(); + fs::write( + bad.join("flow.mmd"), + "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", + ) + .unwrap(); + let output = run_subcommand_with_global(&["validate"], project.path(), global.path()); + assert_eq!( + output.status.code(), + Some(1), + "invalid global checklist fails" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("[global:bad/config.toml]"), + "error label must name the global checklist, got: {stderr}" + ); +} + +#[test] +fn clean_global_removes_global_sessions() { + let global = TempDir::new().unwrap(); + let session = global.path().join("sessions/s1/gate"); + fs::create_dir_all(&session).unwrap(); + let output = run_subcommand_with_global(&["clean", "--global"], global.path(), global.path()); + assert!(output.status.success(), "clean --global should succeed"); + assert!( + !global.path().join("sessions/s1").exists(), + "global session dir must be removed" + ); +} diff --git a/core/tests/gate_integration.rs b/core/tests/gate_integration.rs index a87853c..bb90de0 100644 --- a/core/tests/gate_integration.rs +++ b/core/tests/gate_integration.rs @@ -245,3 +245,153 @@ fn state_json_readable_as_session_state() { assert!(state.visited.is_empty()); assert!(!state.is_complete()); } + +// ── Global checklists ────────────────────────────────────────────────────── + +/// Write a one-step checklist into a bare steplock dir (`/checklists//`). +fn write_global_checklist(steplock_dir: &Path, name: &str, label: &str) { + let cl_dir = steplock_dir.join("checklists").join(name); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + "on_event = \"tool:before\"\non_tool = \"bash\"\nreset = \"session\"\n", + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + format!("stateDiagram-v2\n [*] --> g\n g --> [*]\n g: {label}\n"), + ) + .unwrap(); +} + +fn block_message(resp: HookResponse) -> String { + match resp { + HookResponse::Block { message } => message, + HookResponse::Approve => panic!("expected block"), + _ => panic!("unexpected variant"), + } +} + +#[test] +fn global_checklist_blocks_when_project_has_none() { + let project = tempfile::TempDir::new().unwrap(); + let global = tempfile::TempDir::new().unwrap(); + write_global_checklist(global.path(), "gate", "Global step"); + + let resp = + steplock::run_with_global(&push_event("s1"), project.path(), Some(global.path())).unwrap(); + assert!( + block_message(resp).contains("Global step"), + "global checklist must block" + ); + assert!( + global.path().join("sessions/s1/gate/state.json").exists(), + "state must be stored in the global dir" + ); +} + +#[test] +fn project_checklists_run_before_global() { + let project = tempfile::TempDir::new().unwrap(); + let global = tempfile::TempDir::new().unwrap(); + write_checklist(project.path(), "project-gate", &[("p", "Project step")]); + write_global_checklist(global.path(), "global-gate", "Global step"); + let event = push_event("s1"); + + let first = steplock::run_with_global(&event, project.path(), Some(global.path())).unwrap(); + assert!( + block_message(first).contains("Project step"), + "project checklist must block first" + ); + + ack(project.path(), "project-gate", "s1", "[*]"); + let second = steplock::run_with_global(&event, project.path(), Some(global.path())).unwrap(); + assert!( + block_message(second).contains("Global step"), + "global checklist must block after the project one completes" + ); +} + +#[test] +fn project_checklist_shadows_global_with_same_name() { + let project = tempfile::TempDir::new().unwrap(); + let global = tempfile::TempDir::new().unwrap(); + write_checklist(project.path(), "gate", &[("p", "Project step")]); + write_global_checklist(global.path(), "gate", "Global step"); + let event = push_event("s1"); + + ack_after_block(project.path(), global.path(), &event); + let resp = steplock::run_with_global(&event, project.path(), Some(global.path())).unwrap(); + assert!( + matches!(resp, HookResponse::Approve), + "shadowed global checklist must not run" + ); +} + +/// Block once on the project `gate` checklist, then ack it to completion. +fn ack_after_block(project: &Path, global: &Path, event: &HookEvent) { + let resp = steplock::run_with_global(event, project, Some(global)).unwrap(); + assert!( + block_message(resp).contains("Project step"), + "project checklist must win over same-name global" + ); + ack(project, "gate", "s1", "[*]"); +} + +#[test] +fn empty_project_dir_disables_same_name_global_checklist() { + let project = tempfile::TempDir::new().unwrap(); + let global = tempfile::TempDir::new().unwrap(); + fs::create_dir_all(project.path().join(".steplock/checklists/gate")).unwrap(); + write_global_checklist(global.path(), "gate", "Global step"); + + let resp = + steplock::run_with_global(&push_event("s1"), project.path(), Some(global.path())).unwrap(); + assert!( + matches!(resp, HookResponse::Approve), + "empty same-name project dir must disable the global checklist" + ); +} + +#[test] +fn global_dir_equal_to_project_dir_is_evaluated_once() { + let project = tempfile::TempDir::new().unwrap(); + write_checklist(project.path(), "gate", &[("p", "Project step")]); + let steplock_dir = project.path().join(".steplock"); + let event = push_event("s1"); + + let first = steplock::run_with_global(&event, project.path(), Some(&steplock_dir)).unwrap(); + assert!(block_message(first).contains("Project step"), "blocks once"); + ack(project.path(), "gate", "s1", "[*]"); + let second = steplock::run_with_global(&event, project.path(), Some(&steplock_dir)).unwrap(); + assert!( + matches!(second, HookResponse::Approve), + "same dir must not be evaluated twice" + ); +} + +#[test] +fn session_stop_cleans_global_sessions() { + let project = tempfile::TempDir::new().unwrap(); + let global = tempfile::TempDir::new().unwrap(); + write_global_checklist(global.path(), "gate", "Global step"); + steplock::run_with_global(&push_event("s1"), project.path(), Some(global.path())).unwrap(); + assert!( + global.path().join("sessions/s1").exists(), + "session created" + ); + + let stop = HookEvent::new( + "session:stop".to_owned(), + String::new(), + HashMap::new(), + HashMap::new(), + "s1".to_owned(), + "claude-code".to_owned(), + ); + steplock::run_with_global(&stop, project.path(), Some(global.path())).unwrap(); + assert!( + !global.path().join("sessions/s1").exists(), + "session:stop must clean the global session dir" + ); +}