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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,41 @@ enforcement: soft

See [examples/](examples/) for more configurations and [spec/SPEC.md](spec/SPEC.md) for the full specification.

### Async-heavy users

If you work as "architect and oversee" — sending a few high-level messages while the agent runs long autonomous sequences — the default work timer will overcount your engagement. Rapid-fire agent tool calls look like continuous human work even when you're away from the keyboard.

Set `min_activity_gap_seconds` to filter out autonomous activity. Only gaps at or above the threshold (suggesting you came back and sent a new message) count as work time:

```yaml
sessions:
max_continuous_minutes: 150
min_break_minutes: 15
min_activity_gap_seconds: 60 # ignore gaps under 60s (agent autonomy)
```

Default is `0` (every gap counts — the right setting for hands-on coding). The value is automatically clamped below `min_break_minutes × 60` — setting it higher would prevent any work from being tracked.

### Blocked periods beyond health

`blocked_periods` aren't limited to family time or lunch. They're equally useful for protecting strategic work — deep writing, architecture, planning — from the pull of reactive coding:

```yaml
schedule:
allowed_hours:
start: "09:00"
end: "00:00"
blocked_periods:
- name: "family"
start: "18:00"
end: "21:00"
- name: "deep writing"
start: "09:00"
end: "12:00"
```

"Tuesday/Thursday mornings are writing-only, Claude Code doesn't launch" is a valid and supported configuration. The wrapper will refuse to start a session during those hours, just as it would during family time.

## Two Layers of Enforcement

When installed with `install.sh`, human-guard enforces boundaries at two levels:
Expand Down
6 changes: 6 additions & 0 deletions guard/core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,11 @@ export function computeSessionState(config, nowEpoch, tz) {
}

const minBreakMin = sessions.min_break_minutes || 15;
const minBreakSecs = minBreakMin * 60;
let minActivityGap = parseInt(sessions.min_activity_gap_seconds, 10) || 0;
// Clamp to valid range: non-negative and below min_break_seconds
if (minActivityGap < 0) minActivityGap = 0;
if (minActivityGap >= minBreakSecs) minActivityGap = minBreakSecs - 1;

return {
session_id: randomUUID().replace(/-/g, '').slice(0, 8),
Expand All @@ -667,6 +672,7 @@ export function computeSessionState(config, nowEpoch, tz) {
wind_down_epoch: windDownEpoch,
end_allowed_epoch: endEpoch,
min_break_seconds: minBreakMin * 60,
min_activity_gap_seconds: minActivityGap,
blocked_periods: blockedPeriods,
enforcement: config.enforcement || 'soft',
messages: {
Expand Down
12 changes: 12 additions & 0 deletions guard/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,17 @@ def compute_session_state(config, now_dt, tz):
})

min_break_min = sessions.get("min_break_minutes", 15)
min_break_secs = min_break_min * 60
raw_gap = sessions.get("min_activity_gap_seconds", 0)
# Coerce to int (via float for "60.9" parity with parseInt), clamp to valid range
try:
min_activity_gap = int(float(raw_gap))
except (TypeError, ValueError, OverflowError):
min_activity_gap = 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if min_activity_gap < 0:
min_activity_gap = 0
if min_activity_gap >= min_break_secs:
min_activity_gap = min_break_secs - 1

return {
"session_id": uuid.uuid4().hex[:8],
Expand All @@ -663,6 +674,7 @@ def compute_session_state(config, now_dt, tz):
"wind_down_epoch": wind_down_epoch,
"end_allowed_epoch": end_epoch,
"min_break_seconds": min_break_min * 60,
"min_activity_gap_seconds": min_activity_gap,
"blocked_periods": blocked_periods,
"enforcement": config.get("enforcement", "soft"),
"messages": {
Expand Down
20 changes: 16 additions & 4 deletions guard/hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,31 @@ if [ -n "$SID" ]; then
if [ "$PREV_EPOCH" -gt 0 ] 2>/dev/null; then
GAP=$(( NOW - PREV_EPOCH ))
MIN_BREAK_SECS=$(jq -r '.min_break_seconds // 900' "$STATE" 2>/dev/null || echo 900)
MIN_ACTIVITY_GAP=$(jq -r '.min_activity_gap_seconds // 0' "$STATE" 2>/dev/null || echo 0)
PREV_WSB=$(cat "$WSB_FILE" 2>/dev/null || echo 0)
if [ "$GAP" -ge "$MIN_BREAK_SECS" ]; then
# Intra-session break detected — reset work counter
echo "0" > "$WSB_FILE" 2>/dev/null
echo "$NOW" > "$ACTIVITY_FILE" 2>/dev/null
elif [ "$MIN_ACTIVITY_GAP" -gt 0 ] && [ "$GAP" -lt "$MIN_ACTIVITY_GAP" ]; then
# Autonomous agent work — gap too short to be human engagement
# Create wsb sentinel but do NOT update activity (preserve last human interaction)
[ ! -f "$WSB_FILE" ] && echo "0" > "$WSB_FILE" 2>/dev/null
else
# Continuous work — accumulate seconds (converted to minutes by endSession)
# Human engagement — accumulate seconds (converted to minutes by endSession)
echo "$(( PREV_WSB + GAP ))" > "$WSB_FILE" 2>/dev/null
echo "$NOW" > "$ACTIVITY_FILE" 2>/dev/null
fi
else
# Non-numeric sentinel (e.g. ISO timestamp from touch_session) — reinitialize
[ ! -f "$WSB_FILE" ] && echo "0" > "$WSB_FILE" 2>/dev/null
echo "$NOW" > "$ACTIVITY_FILE" 2>/dev/null
fi
else
# First tool call in session — initialize wsb sentinel and activity
[ ! -f "$WSB_FILE" ] && echo "0" > "$WSB_FILE" 2>/dev/null
echo "$NOW" > "$ACTIVITY_FILE" 2>/dev/null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fi

# Store epoch (portable across BSD/GNU — no date format parsing needed)
echo "$NOW" > "$ACTIVITY_FILE" 2>/dev/null
fi

# Emit a one-shot systemMessage (only when session-managed).
Expand Down
47 changes: 47 additions & 0 deletions tests/test_guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1645,3 +1645,50 @@ describe('Intra-session breaks', () => {
'30s = 0.5min should round to 1 (Math.round rounds .5 up)');
});
});


// ===========================================================================
// 8. Engagement gap — filter autonomous agent tool calls (#9)
// ===========================================================================

describe('Engagement gap (min_activity_gap_seconds)', () => {
it('computeSessionState includes min_activity_gap_seconds from config', () => {
const config = parseYaml(SAMPLE_YAML.replace(
'min_break_minutes: 15',
'min_break_minutes: 15\n min_activity_gap_seconds: 120'
));
const now = Math.floor(fakeNow(2026, 2, 27, 15, 0).getTime() / 1000);
const state = computeSessionState(config, now, 'Europe/London');
assert.strictEqual(state.min_activity_gap_seconds, 120);
});

it('computeSessionState defaults min_activity_gap_seconds to 0', () => {
const config = parseYaml(SAMPLE_YAML);
const now = Math.floor(fakeNow(2026, 2, 27, 15, 0).getTime() / 1000);
const state = computeSessionState(config, now, 'Europe/London');
assert.strictEqual(state.min_activity_gap_seconds, 0);
});

it('YAML parser reads min_activity_gap_seconds', () => {
const config = parseYaml(SAMPLE_YAML.replace(
'min_break_minutes: 15',
'min_break_minutes: 15\n min_activity_gap_seconds: 60'
));
assert.strictEqual(config.sessions.min_activity_gap_seconds, 60);
});

it('negative min_activity_gap_seconds clamped to 0', () => {
const config = { ...SAMPLE_CONFIG, sessions: { ...SAMPLE_CONFIG.sessions, min_activity_gap_seconds: -5 } };
const now = Math.floor(fakeNow(2026, 2, 27, 15, 0).getTime() / 1000);
const state = computeSessionState(config, now, 'Europe/London');
assert.strictEqual(state.min_activity_gap_seconds, 0);
});

it('min_activity_gap_seconds >= min_break_seconds clamped', () => {
const config = { ...SAMPLE_CONFIG, sessions: { ...SAMPLE_CONFIG.sessions, min_activity_gap_seconds: 1200 } };
const now = Math.floor(fakeNow(2026, 2, 27, 15, 0).getTime() / 1000);
const state = computeSessionState(config, now, 'Europe/London');
assert.ok(state.min_activity_gap_seconds < state.min_break_seconds,
`${state.min_activity_gap_seconds} should be < ${state.min_break_seconds}`);
});
});
Loading
Loading