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
- `ack.sh` left the previous state's `transitions` in `state.json`, so a second ack before the hook fired again failed with `invalid next state ''`. The agent had to retry the gated command after every step. `ack.sh` now sets `transitions` and `next_state` for the new state, and a session started by an older steplock gets the fixed `ack.sh` on its next block

## [0.2.0] - 2026-09-29

### Changed
Expand Down
5 changes: 4 additions & 1 deletion core/scripts/ack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ if [ -z "$MATCHED" ]; then
exit 1
fi

# Set the new state's transitions from the flow, so the next ack works
# without the hook firing in between.
jq --arg cur "$CURRENT" --arg next "$NEXT" '
.visited += [$cur] |
.current_state = $next |
.next_state = null
.transitions = (.flow_transitions[$next] // []) |
.next_state = (if (.transitions | length) == 1 then .transitions[0] else null end)
' "$STATE" > "$TMP" && mv "$TMP" "$STATE"

# Append ack event to audit.log — failures silently ignored (audit must never block).
Expand Down
41 changes: 0 additions & 41 deletions core/src/ack.sh

This file was deleted.

3 changes: 3 additions & 0 deletions core/src/gate.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Checklist gate: decides whether one checklist blocks a hook event.
use std::collections::HashMap;
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
Expand Down Expand Up @@ -90,6 +91,7 @@ fn block_reset_always(
current_state: initial_state.to_owned(),
next_state,
transitions,
flow_transitions: HashMap::new(),
visited: vec![],
};
audit::append(
Expand Down Expand Up @@ -152,6 +154,7 @@ fn block_reset_session(
.cloned()
.filter(|_| raw_transitions.len() == 1);
state.transitions = raw_transitions;
state.flow_transitions.clone_from(&flow.transitions);

save_state(&state_path, &state)?;
scripts::ensure_ack_sh(&session_dir)?;
Expand Down
3 changes: 3 additions & 0 deletions core/src/run_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ fn approves_and_resets_state_when_complete() {
current_state: "[*]".to_owned(),
next_state: None,
transitions: vec![],
flow_transitions: HashMap::new(),
visited: vec!["clean_code".to_owned()],
};
save_state(&session_dir.join("state.json"), &state).unwrap();
Expand Down Expand Up @@ -635,6 +636,7 @@ fn unknown_current_state_in_flow_skips_checklist() {
current_state: "nonexistent_state".to_owned(),
next_state: None,
transitions: vec![],
flow_transitions: HashMap::new(),
visited: vec![],
};
save_state(&session_dir.join("state.json"), &state).unwrap();
Expand All @@ -660,6 +662,7 @@ fn complete_event_written_to_audit_log() {
current_state: "[*]".to_owned(),
next_state: None,
transitions: vec![],
flow_transitions: HashMap::new(),
visited: vec!["clean_code".to_owned()],
};
save_state(&session_dir.join("state.json"), &state).unwrap();
Expand Down
5 changes: 3 additions & 2 deletions core/src/scripts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ use crate::flow::FlowGraph;

static ACK_SH: &str = include_str!("../scripts/ack.sh");

/// Write ack.sh to `dir` only if it does not already exist.
/// Write ack.sh to `dir` unless it already holds the current script.
/// A session started by an older steplock gets the current script on its next block.
///
/// # Errors
///
/// Returns `Err` if writing the file or setting its permissions fails.
pub fn ensure_ack_sh(dir: &Path) -> Result<()> {
let path = dir.join("ack.sh");
if path.exists() {
if fs::read_to_string(&path).is_ok_and(|s| s == ACK_SH) {
return Ok(());
}
write_executable(&path, ACK_SH)
Expand Down
7 changes: 3 additions & 4 deletions core/src/scripts_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,12 @@ fn ack_sh_appends_audit_event() {
}

#[test]
fn ensure_ack_sh_is_idempotent() {
fn ensure_ack_sh_replaces_stale_script() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("ack.sh");
fs::write(&path, "custom content").unwrap();
fs::write(&path, "old script").unwrap();
ensure_ack_sh(tmp.path()).unwrap();
// Should not overwrite existing file
assert_eq!(fs::read_to_string(&path).unwrap(), "custom content");
assert_eq!(fs::read_to_string(&path).unwrap(), ACK_SH);
}

#[test]
Expand Down
5 changes: 5 additions & 0 deletions core/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ pub struct SessionState {
pub next_state: Option<String>,
/// All valid next state names from `current_state` (including `"[*]"`).
pub transitions: Vec<String>,
/// Outgoing transitions for every state in the flow, so `ack.sh` can set
/// `transitions` and `next_state` for the state it advances to.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub flow_transitions: HashMap<String, Vec<String>>,
/// States that have been acknowledged in this session so far.
pub visited: Vec<String>,
}
Expand Down Expand Up @@ -66,6 +70,7 @@ pub fn init_state(checklist: &str, initial_state: &str) -> SessionState {
current_state: initial_state.to_owned(),
next_state: None,
transitions: Vec::new(),
flow_transitions: HashMap::new(),
visited: Vec::new(),
}
}
Expand Down
3 changes: 3 additions & 0 deletions core/src/state_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Unit tests for `state`.
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;

#[test]
Expand All @@ -25,6 +26,7 @@ fn is_complete_true_at_end() {
current_state: "[*]".to_owned(),
next_state: None,
transitions: vec![],
flow_transitions: HashMap::new(),
visited: vec!["step_one".to_owned()],
};
assert!(s.is_complete());
Expand All @@ -39,6 +41,7 @@ fn save_and_load_roundtrip() {
current_state: "check_one".to_owned(),
next_state: Some("check_two".to_owned()),
transitions: vec!["check_two".to_owned()],
flow_transitions: HashMap::new(),
visited: vec!["prev".to_owned()],
};
save_state(&path, &s).unwrap();
Expand Down
47 changes: 47 additions & 0 deletions core/tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,50 @@ fn hermes_hook_uses_real_home_global_checklist_when_home_is_sandboxed() {
"global checklist from the real home must run: {json}"
);
}

#[test]
fn ack_sh_advances_twice_without_a_hook_call_between() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".steplock/checklists/gate");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("config.toml"),
"on_event = \"tool:before\"\non_tool = \"bash\"\nreset = \"session\"\n",
)
.unwrap();
fs::write(
dir.join("flow.mmd"),
"stateDiagram-v2\n [*] --> one\n one --> two\n two --> left\n two --> right\n left --> three\n right --> three\n three --> [*]\n one: Step one\n two: Step two\n left: Go left\n right: Go right\n three: Step three\n",
)
.unwrap();

let stdin = hook_event("bash", "git push", "sess-ack");
let (first_code, _, _) = run_steplock(tmp.path(), &stdin);
assert_eq!(first_code, 0, "first call blocks at step one");

let ack = tmp.path().join(".steplock/sessions/sess-ack/gate/ack.sh");
let run_ack = |arg: Option<&str>| {
let mut cmd = Command::new("sh");
cmd.arg(&ack);
if let Some(a) = arg {
cmd.arg(a);
}
cmd.output().unwrap()
};
// one -> two (linear), two -> right (branch), right -> three (linear): no hook call between.
for arg in [None, Some("right"), None] {
let out = run_ack(arg);
assert!(
out.status.success(),
"ack {arg:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}

let (code, stdout, _) = run_steplock(tmp.path(), &stdin);
assert_eq!(code, 0);
assert!(
stdout.contains("Step three"),
"expected block at step three, got: {stdout}"
);
}
5 changes: 5 additions & 0 deletions schemas/session-state.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
"description": "Valid next state names from current_state. ack.sh validates $1 against this list.",
"items": { "type": "string" }
},
"flow_transitions": {
"type": "object",
"description": "Outgoing transitions for every state in the flow. ack.sh reads it to set transitions and next_state for the state it advances to.",
"additionalProperties": { "type": "array", "items": { "type": "string" } }
},
"visited": {
"type": "array",
"description": "State node names already acknowledged this scope. Written by ack.sh.",
Expand Down
Loading