Skip to content

Commit 3e244c7

Browse files
m3meclaude
andcommitted
Activity-aware duration tracking and release versioning (v0.3.0)
Governor now distinguishes "terminal open" from "human working" by detecting idle gaps between tool calls. Gaps exceeding a configurable threshold (default 30 min) are recorded and subtracted from session/mode durations. Resolves the overnight-terminal false nudge problem (Design Thread #7). Adds proper release infrastructure: _version.py as single source of truth, EXPORT_SCHEMA_VERSION constant, CHANGELOG.md retro-filled from commit history, annotated git tags for all releases. Export schema: 0.2.0 -> 0.3.0 (adds active_duration_minutes, idle_gaps). 116 tests passing (26 new). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0d264ba commit 3e244c7

File tree

10 files changed

+486
-23
lines changed

10 files changed

+486
-23
lines changed

CHANGELOG.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Changelog
2+
3+
All notable changes to Vibe Harness are documented in this file.
4+
5+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6+
Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
Export schema version is tracked independently — it describes the JSON export
9+
format, not the software release. Schema version is noted in each release when
10+
it changes.
11+
12+
## [Unreleased]
13+
14+
## [0.3.0] - 2026-02-13
15+
16+
Activity-aware duration tracking. The governor now distinguishes "terminal open"
17+
from "human working" by detecting idle gaps between tool calls.
18+
19+
### Added
20+
- `IdleGap` dataclass for recording detected idle periods
21+
- `active_session_minutes()` and `active_mode_minutes()` on `VibeSession` — elapsed minus idle time
22+
- `total_idle_minutes()` for total detected idle time
23+
- `activity.idle_threshold_minutes` config setting (default: 30)
24+
- `away Xh detected` line in `vibe_check()` output when idle time exists
25+
- `active_duration_minutes` and `idle_gaps` fields in session export
26+
- `_version.py` as single source of truth for package version
27+
- `EXPORT_SCHEMA_VERSION` constant in `session.py`
28+
- `CHANGELOG.md` (this file), retro-filled from commit history
29+
- Git tags for all releases (`v0.1.0`, `v0.2.0`, `v0.3.0`)
30+
- 26 new tests (116 total)
31+
32+
### Changed
33+
- Governor evaluates rules against active durations, not raw elapsed time
34+
- `vibe_check()` and status line display active durations
35+
- `time_in_mode_summary()` subtracts idle gaps per mode span
36+
- Export schema version: `0.2.0``0.3.0`
37+
38+
### Fixed
39+
- Overnight/idle terminal no longer triggers false "step away" nudge
40+
41+
## [0.2.0] - 2026-02-06
42+
43+
Defeasible governance with full accountability trace.
44+
45+
### Added
46+
- `GovernanceRule` and `RuleEvaluation` dataclasses
47+
- Five priority-ordered rules: cooldown suppression, session duration, mode duration, mode drift, interaction count
48+
- Full governance trace recorded on every evaluation — which rules fired, which were defeated, and by what
49+
- `evaluate_rules()` returns `(message, trace)` tuple
50+
- Governance trace included in session export
51+
- ESL-A v0.1 license
52+
- Slash commands: `/vibe`, `/vibe-mode`, `/vibe-history`
53+
54+
### Changed
55+
- Governor architecture: from simple threshold checks to defeasible rule engine
56+
- Export schema version: `0.1.0``0.2.0`
57+
58+
## [0.1.0] - 2026-02-05
59+
60+
Initial release. Layer 1: manual mode switching with research-informed presets.
61+
62+
### Added
63+
- FastMCP server with stdio transport
64+
- 5 working modes: explore, build, think-with, ship, cool-off
65+
- Transition friction matrix (none/medium/high) with double-call confirmation for high friction
66+
- `vibe_set_mode()`, `vibe_check()`, `vibe_nudge()`, `vibe_history()`, `vibe_session_export()`, `vibe_configure()` tools
67+
- `vibe://context` and `vibe://status` MCP resources
68+
- Three-layer config resolution: defaults < user < project < runtime
69+
- JSONL mode history at `~/.vibe-harness/mode-history.jsonl`
70+
- Versioned JSON session export
71+
- Onboarding message for first-time users
72+
- 73 unit tests
73+
74+
[Unreleased]: https://github.com/m3data/vibe-harness-mcp/compare/v0.3.0...HEAD
75+
[0.3.0]: https://github.com/m3data/vibe-harness-mcp/compare/v0.2.0...v0.3.0
76+
[0.2.0]: https://github.com/m3data/vibe-harness-mcp/compare/v0.1.0...v0.2.0
77+
[0.1.0]: https://github.com/m3data/vibe-harness-mcp/releases/tag/v0.1.0

_version.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Single source of truth for the package version.
2+
3+
Update this when cutting a release. pyproject.toml must match.
4+
"""
5+
6+
__version__ = "0.3.0"

config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"nudges.cooldown_minutes": 15,
1717
"friction.enabled": True,
1818
"export.auto_export": False,
19+
"activity.idle_threshold_minutes": 30,
1920
}
2021

2122
USER_CONFIG = Path.home() / ".vibe-harness" / "config.json"

formatters.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ def format_vibe_check(session, *, nudge: Optional[str] = None) -> str:
2828
if not mode_def:
2929
return "Session state unavailable."
3030

31-
mode_min = round(session.mode_duration_minutes())
32-
session_min = round(session.session_duration_minutes())
31+
mode_min = round(session.active_mode_minutes())
32+
session_min = round(session.active_session_minutes())
33+
idle_min = round(session.total_idle_minutes())
3334

3435
lines = [
3536
f"Mode: {mode_def['name']}",
@@ -42,6 +43,13 @@ def format_vibe_check(session, *, nudge: Optional[str] = None) -> str:
4243
f" switches {len(session.transitions)}",
4344
]
4445

46+
if idle_min > 0:
47+
hours = idle_min / 60
48+
if hours >= 1:
49+
lines.append(f" away {hours:.1f}h detected")
50+
else:
51+
lines.append(f" away {idle_min}min detected")
52+
4553
if session.nudges_surfaced > 0:
4654
lines.append(f" nudges {session.nudges_surfaced}")
4755

@@ -61,7 +69,7 @@ def format_status_line(session) -> str:
6169
"""One-line status for vibe://status resource."""
6270
mode_def = get_mode(session.mode)
6371
name = mode_def["name"] if mode_def else session.mode
64-
mode_min = round(session.mode_duration_minutes())
72+
mode_min = round(session.active_mode_minutes())
6573
return f"{name} | {mode_min}min | {session.interaction_count} interactions | {session.nudges_surfaced} nudges"
6674

6775

governor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ def _build_session_state(session) -> dict:
7676
now = datetime.now(timezone.utc)
7777
return {
7878
"mode": session.mode,
79-
"mode_minutes": session.mode_duration_minutes(),
80-
"session_minutes": session.session_duration_minutes(),
79+
"mode_minutes": session.active_mode_minutes(),
80+
"session_minutes": session.active_session_minutes(),
8181
"interactions": session.interaction_count,
8282
"switches": len(session.transitions),
8383
"last_nudge_at": session.last_nudge_at,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vibe-harness"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
description = "MCP server that tunes human-AI interaction rhythm based on working modes and biosignal data"
55
requires-python = ">=3.10"
66
license = "ESL-A-0.1"

server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from mcp.server.fastmcp import FastMCP
1515

16+
from _version import __version__
1617
from session import VibeSession
1718
from modes import valid_modes, get_mode
1819
from formatters import format_mode_switch, format_vibe_check, format_status_line, format_history, format_nudge_output, format_onboarding

session.py

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,34 @@
77
from typing import Optional
88
import uuid
99

10+
import config
1011
from modes import get_friction, get_friction_message, default_mode, validate_mode, get_mode
1112

1213
HISTORY_DIR = Path.home() / ".vibe-harness"
1314
HISTORY_FILE = HISTORY_DIR / "mode-history.jsonl"
1415

16+
# Export schema version — tracks the JSON export format independently of the
17+
# package version. Bump when adding/removing/renaming fields in to_export_dict().
18+
EXPORT_SCHEMA_VERSION = "0.3.0"
19+
20+
21+
@dataclass
22+
class IdleGap:
23+
"""A detected period of inactivity between tool calls."""
24+
start: datetime
25+
end: datetime
26+
27+
@property
28+
def duration_seconds(self) -> float:
29+
return (self.end - self.start).total_seconds()
30+
31+
def to_dict(self) -> dict:
32+
return {
33+
"start": self.start.isoformat(),
34+
"end": self.end.isoformat(),
35+
"duration_minutes": round(self.duration_seconds / 60, 1),
36+
}
37+
1538

1639
@dataclass
1740
class ModeTransition:
@@ -51,13 +74,29 @@ class VibeSession:
5174
# Tracks interaction count at last mode switch for per-mode counting
5275
_interactions_at_last_switch: int = field(default=0, repr=False)
5376

77+
# Activity tracking for idle gap detection
78+
last_interaction_at: Optional[datetime] = field(default=None, repr=False)
79+
_idle_gaps: list[IdleGap] = field(default_factory=list, repr=False)
80+
5481
# High-friction confirmation state
5582
_pending_mode: Optional[str] = field(default=None, repr=False)
5683
_pending_friction: Optional[str] = field(default=None, repr=False)
5784
_onboarding_shown: bool = field(default=False, repr=False)
5885

5986
def record_interaction(self) -> None:
60-
"""Increment interaction counter. Call on every tool invocation."""
87+
"""Increment interaction counter and detect idle gaps.
88+
89+
Call on every tool invocation. If the gap since the last interaction
90+
exceeds the idle threshold, records an IdleGap so active duration
91+
calculations can subtract idle time.
92+
"""
93+
now = datetime.now(timezone.utc)
94+
if self.last_interaction_at is not None:
95+
gap_seconds = (now - self.last_interaction_at).total_seconds()
96+
threshold_minutes = config.get("activity.idle_threshold_minutes")
97+
if gap_seconds >= threshold_minutes * 60:
98+
self._idle_gaps.append(IdleGap(start=self.last_interaction_at, end=now))
99+
self.last_interaction_at = now
61100
self.interaction_count += 1
62101

63102
def record_governance_evaluation(self, evaluations: list[dict]) -> None:
@@ -197,50 +236,99 @@ def session_duration_minutes(self) -> float:
197236
delta = datetime.now(timezone.utc) - self.started_at
198237
return delta.total_seconds() / 60
199238

239+
def _total_idle_seconds(self, since: datetime) -> float:
240+
"""Sum idle gap durations that fall after `since`.
241+
242+
Handles gaps that straddle the `since` boundary by only counting
243+
the portion after `since`.
244+
"""
245+
total = 0.0
246+
for gap in self._idle_gaps:
247+
if gap.end <= since:
248+
continue
249+
effective_start = max(gap.start, since)
250+
total += (gap.end - effective_start).total_seconds()
251+
return total
252+
253+
def active_session_minutes(self) -> float:
254+
"""Session duration minus idle time."""
255+
elapsed = self.session_duration_minutes()
256+
idle = self._total_idle_seconds(self.started_at) / 60
257+
return max(0.0, elapsed - idle)
258+
259+
def active_mode_minutes(self) -> float:
260+
"""Current mode duration minus idle time since mode started."""
261+
elapsed = self.mode_duration_minutes()
262+
idle = self._total_idle_seconds(self.mode_since) / 60
263+
return max(0.0, elapsed - idle)
264+
265+
def total_idle_minutes(self) -> float:
266+
"""Total idle time detected across the session."""
267+
return self._total_idle_seconds(self.started_at) / 60
268+
200269
def interactions_since_last_switch(self) -> int:
201270
"""Interactions since last mode transition."""
202271
return self.interaction_count - self._interactions_at_last_switch
203272

204273
def time_in_mode_summary(self) -> dict[str, float]:
205-
"""Calculate time spent in each mode during this session."""
274+
"""Calculate active time spent in each mode during this session.
275+
276+
Subtracts idle gaps from each mode's time allocation so the summary
277+
reflects active time, not wall-clock time.
278+
"""
206279
summary: dict[str, float] = {}
207280
now = datetime.now(timezone.utc)
208281

209282
if not self.transitions:
210283
mode = self.mode
211-
minutes = (now - self.started_at).total_seconds() / 60
212-
summary[mode] = round(minutes, 1)
284+
elapsed = (now - self.started_at).total_seconds() / 60
285+
idle = self._total_idle_seconds(self.started_at) / 60
286+
summary[mode] = round(max(0.0, elapsed - idle), 1)
213287
return summary
214288

289+
# Build list of (mode, start, end) spans
290+
spans: list[tuple[str, datetime, datetime]] = []
291+
215292
# Time from session start to first transition
216293
first = self.transitions[0]
217-
start_minutes = (first.timestamp - self.started_at).total_seconds() / 60
218-
initial_mode = first.from_mode
219-
summary[initial_mode] = summary.get(initial_mode, 0) + start_minutes
294+
spans.append((first.from_mode, self.started_at, first.timestamp))
220295

221296
# Time between transitions
222297
for i, t in enumerate(self.transitions):
223-
if i + 1 < len(self.transitions):
224-
end = self.transitions[i + 1].timestamp
225-
else:
226-
end = now
227-
minutes = (end - t.timestamp).total_seconds() / 60
228-
summary[t.to_mode] = summary.get(t.to_mode, 0) + minutes
298+
end = self.transitions[i + 1].timestamp if i + 1 < len(self.transitions) else now
299+
spans.append((t.to_mode, t.timestamp, end))
300+
301+
# Accumulate active time per mode
302+
for mode, span_start, span_end in spans:
303+
elapsed = (span_end - span_start).total_seconds() / 60
304+
idle = self._total_idle_seconds(span_start) / 60
305+
# Only count idle time within this span
306+
idle_in_span = 0.0
307+
for gap in self._idle_gaps:
308+
if gap.end <= span_start or gap.start >= span_end:
309+
continue
310+
effective_start = max(gap.start, span_start)
311+
effective_end = min(gap.end, span_end)
312+
idle_in_span += (effective_end - effective_start).total_seconds() / 60
313+
active = max(0.0, elapsed - idle_in_span)
314+
summary[mode] = summary.get(mode, 0) + active
229315

230316
return {k: round(v, 1) for k, v in summary.items()}
231317

232318
def to_export_dict(self) -> dict:
233319
"""Full session data for JSON export."""
234320
return {
235-
"schema_version": "0.2.0",
321+
"schema_version": EXPORT_SCHEMA_VERSION,
236322
"session_id": self.session_id,
237323
"started_at": self.started_at.isoformat(),
238324
"exported_at": datetime.now(timezone.utc).isoformat(),
239325
"duration_minutes": round(self.session_duration_minutes(), 1),
326+
"active_duration_minutes": round(self.active_session_minutes(), 1),
240327
"current_mode": self.mode,
241328
"interaction_count": self.interaction_count,
242329
"nudges_surfaced": self.nudges_surfaced,
243330
"transitions": [t.to_dict() for t in self.transitions],
331+
"idle_gaps": [g.to_dict() for g in self._idle_gaps],
244332
"time_in_mode": self.time_in_mode_summary(),
245333
"governance_trace": self.governance_trace,
246334
}

0 commit comments

Comments
 (0)