Skip to content

feat: support global checklists shared by every project - #209

Merged
tupe12334 merged 4 commits into
mainfrom
feat/global-checklists
Sep 24, 2026
Merged

tupe12334 merged 4 commits into
mainfrom
feat/global-checklists

Conversation

@tupe12334

Copy link
Copy Markdown
Member

Summary

  • Adds a global steplock directory whose checklists apply to every project, including projects with no .steplock/. Lookup order: $STEPLOCK_GLOBAL_DIR (an empty value turns it off), then $XDG_CONFIG_HOME/steplock, then ~/.config/steplock. The layout is the same as .steplock/ (checklists/, sessions/, audit.log).
  • Precedence: project checklists run first, then global ones. A project checklist with the same name overrides the global one, and an empty .steplock/checklists/<name>/ turns that global gate off for the project. Global session state is written to the global directory, so nothing gets written into projects that have no .steplock/. If the global directory resolves to the project .steplock/, it is evaluated only once. session:stop cleans up both locations.
  • CLI: adds steplock init --global and steplock clean --global. steplock validate now also checks global checklists; their errors are labelled [global:<name>/...].
  • Library: adds run_with_global(event, repo_root, global_dir) and global_steplock_dir(). run() keeps its current behaviour (project only).
  • Lint ratchet: clippy::manual_let_else is now deny (no existing violations).

Motivation: gate an action such as git push for every repo an agent works in, from one user-level hook (~/.claude/settings.json) plus one global checklist. Before this change, the only option was a .steplock/ in a shared parent directory like ~. That broke as soon as a repo had its own .steplock/, because the nearest directory wins and nothing merges.

Test plan

  • cargo test passes (6 new unit tests for directory resolution, 6 new gate integration tests, 6 new CLI tests)
  • cargo clippy --all-targets -- -D warnings passes
  • cargo fmt -- --check passes
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps passes
  • Manual smoke test: with a temporary HOME and an empty project directory, ran steplock init --global, then sent Claude Code PreToolUse events for git push. Step 1 blocked, ack.sh ran, step 2 blocked, ack.sh ran, then the push was approved. ls was approved immediately. All state was written under ~/.config/steplock/ and none in the project. validate and clean --global report correctly.

The CLI tests now set STEPLOCK_GLOBAL_DIR="" by default, so a developer's real global checklists cannot affect the test results.

🤖 Generated with Claude Code

tupe12334 and others added 2 commits September 24, 2026 10:04
Checklists in the global steplock directory ($STEPLOCK_GLOBAL_DIR, else
$XDG_CONFIG_HOME/steplock, else ~/.config/steplock) now apply to every
project, including projects without a .steplock/ directory.

- Project checklists run first, then global checklists.
- A project checklist with the same name overrides the global one; an
  empty directory with that name turns it off.
- Global session state and audit events live in the global directory.
- New CLI: `steplock init --global`, `steplock clean --global`;
  `steplock validate` also checks global checklists.
- New library API: `run_with_global`, `global_steplock_dir`.
- Promote clippy::manual_let_else to deny.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tests used Unix-only absolute paths, and Windows usually has no HOME.
Fall back to USERPROFILE and build test paths per platform.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread core/src/run.rs Outdated

/// Checklist directories under `<steplock_dir>/checklists/`, sorted by name.
/// Returns an empty list when the directory does not exist.
fn checklist_dirs(steplock_dir: &Path) -> Result<Vec<PathBuf>> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract to a file

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a517070: moved to core/src/catalog.rs (checklist_dirs).

Comment thread core/src/run.rs Outdated
.unwrap_or("")
.to_owned();
/// Evaluate one checklist. Returns `Some(Block)` when it blocks the event, `None` otherwise.
fn evaluate_checklist(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract to a file

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a517070: moved evaluate_checklist to core/src/gate.rs, together with the helpers only it uses (block_reset_always, block_reset_session, get_scope_key, build_block_message), so gate.rs doesn't import back from run.rs. run.rs now only handles the project/global orchestration and session:stop cleanup.

Comment thread core/src/run.rs Outdated
}

/// `true` when both paths resolve to the same existing directory.
fn is_same_dir(a: &Path, b: &Path) -> bool {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use lib

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a517070: replaced is_same_dir with same_file::is_same_file (MIT/Unlicense, already allowed by deny.toml). An error (e.g. a missing path) counts as not the same directory.

Comment thread core/tests/cli.rs
Comment on lines 45 to 50
let mut child = Command::new(STEPLOCK)
.current_dir(root)
.env("STEPLOCK_GLOBAL_DIR", global)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this logic twice, extract

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a517070: the parent-directory test now calls the shared run_steplock helper instead of its own copy of the spawn/stdin logic. The new global subcommand tests use one run_subcommand_with_global helper.

@tupe12334

Copy link
Copy Markdown
Member Author

Open pr to use lib for the cli, and open a pr to extract the tests to _tests.rs files

@tupe12334
tupe12334 marked this pull request as ready for review September 24, 2026 07:15
- Move checklist discovery into catalog.rs.
- Move per-checklist gate evaluation (and the block/message helpers it
  owns) into gate.rs; run.rs now only orchestrates project and global
  directories.
- Use the same-file crate instead of hand-rolled canonicalize comparison.
- Share one spawn helper across CLI tests instead of duplicating it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread core/src/global_config.rs Outdated
Comment on lines +28 to +46
fn resolve_global_dir(var: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
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", "USERPROFILE"]
.into_iter()
.filter_map(&var)
.find(|home| !home.is_empty())
.map(|home| PathBuf::from(home).join(".config").join("steplock"))
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a lib for the home dir, i have one use it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b97401f: the lookup now uses dirs::home_dir(). replace-homedir has no public home-dir getter (only replace_homedir(path, replacement)), so I used dirs, the crate it is built on. The home dir is passed into resolve_global_dir, so the tests stay free of env.

Replace the hand-rolled HOME/USERPROFILE lookup with dirs::home_dir(),
the same crate replace-homedir builds on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@tupe12334

Copy link
Copy Markdown
Member Author

Follow-ups opened as stacked draft PRs (they touch the same files as this one):

The home-dir comment is addressed here in b97401f (dirs::home_dir()).

@tupe12334
tupe12334 merged commit 49102c2 into main Sep 24, 2026
14 checks passed
@tupe12334
tupe12334 deleted the feat/global-checklists branch September 24, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant