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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
- **AI Agent Context & Discovery Parity:** Updated `llms.txt` with timestamp `2026-09-22`, updated test baseline, and cross-references to `NOTICE` and `THIRD_PARTY_LICENSES.md`.
- **Extended Contract Verification Suite:** Expanded `tests/test_metadata.py` to assert house files existence, NOTICE integrity, SBOM structure, CI workflow hardening, .gitignore coverage, and legal disclaimers.

### Notaus-Aufhebung verlustfrei (2026-09-20)
- **Notaus-Aufhebung verlustfrei:** `token_budget_guard.py` übernimmt die
Parkvermerke `paused_goals` und `paused_agents` bei automatischen
State-Übergängen und meldet eine ausstehende Aufhebung. Der
`notaus_wake_check.py` macht erhaltene Parkkreise beim nächsten Wecken
sichtbar, statt sie still zu übergehen.
- **Tests:** Regressionstests decken den automatischen Rücklauf mit
pausierten Goals/Workern und die Wake-Meldung ab.

## [0.2.0] - 2026-08-25 (Ergänzt 2026-09-10)

### Pfad B Discoverability, Mermaid-Architektur & Metadaten (2026-09-10)
Expand Down
18 changes: 17 additions & 1 deletion library/notaus_wake_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
import sys
import time


STATE_DIR = os.path.join(os.path.expanduser("~"), ".claude", "state")
SPARMODUS_STATE_PATH = os.path.join(STATE_DIR, "sparmodus_state.json")

Expand All @@ -61,6 +60,20 @@ def _read_json(path: str) -> dict:
return {}


def _pending_resume_hint(state: dict) -> str:
"""Make preserved park markers visible at the next wake check."""
parts = []
goals = state.get("paused_goals")
agents = state.get("paused_agents")
if isinstance(goals, list):
parts.append(f"{len(goals)} pausierte Goals")
if isinstance(agents, list):
parts.append(f"{len(agents)} geparkte Worker")
if not parts:
return ""
return " Aufhebung ausstehend (" + ", ".join(parts) + "); zuerst den vollständigen Parkkreis reaktivieren."


def main() -> int:
_ensure_utf8_stdio()
raw = sys.stdin.read()
Expand All @@ -79,6 +92,7 @@ def main() -> int:
reason = state.get("reason", "")
set_at = state.get("set_at")
prior_mode = state.get("prior_mode") or "off"
pending_hint = _pending_resume_hint(state)

known_wake_target = wake_at if isinstance(wake_at, (int, float)) else resets_at

Expand All @@ -91,6 +105,7 @@ def main() -> int:
"Guthaben wieder da, Abschnitt 'Aufheben' aus Skill notaus "
f"ausfuehren (Rueckkehr zu Modus '{prior_mode}') statt den Zustand "
"zu ignorieren."
+ pending_hint
)
else:
wake_hint = (
Expand All @@ -104,6 +119,7 @@ def main() -> int:
"GEPARKT bleiben: keine neue Arbeit beginnen, laufende Delegationen "
"nicht erweitern, nur den Zustand pruefen und ggf. auf das naechste "
"Aufwachfenster verweisen. Details: Skill 'notaus'."
+ pending_hint
)

print(json.dumps({
Expand Down
45 changes: 38 additions & 7 deletions library/token_budget_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@
import time
from typing import Any


STATE_DIR = os.path.join(os.path.expanduser("~"), ".claude", "state")
HOOKS_DIR = os.path.join(os.path.expanduser("~"), ".claude", "hooks")
BRIDGE_PATH = os.path.join(STATE_DIR, "token_budget.json")
Expand Down Expand Up @@ -182,6 +181,34 @@ def _default_sparmodus_state() -> dict:
}


def _preserve_park_fields(previous: dict, current: dict) -> dict:
"""Carry the notaus ownership markers across an automatic state reset.

``paused_goals`` and ``paused_agents`` are written by ``notaus_tools``
before the budget hook performs its automatic return. Rebuilding the
state dictionary must not erase those markers: the resume step needs the
exact set that this host parked.
"""
for key in ("paused_goals", "paused_agents"):
if key in previous:
current[key] = previous[key]
return current


def _pending_resume_hint(state: dict) -> str:
"""Describe preserved park markers without guessing their contents."""
parts = []
goals = state.get("paused_goals")
agents = state.get("paused_agents")
if isinstance(goals, list):
parts.append(f"{len(goals)} pausierte Goals")
if isinstance(agents, list):
parts.append(f"{len(agents)} geparkte Worker")
if not parts:
return ""
return " Aufhebung ausstehend (" + ", ".join(parts) + "); zuerst den vollständigen Parkkreis reaktivieren."


def _compute_transition(mode: str, stage: int) -> str | None:
"""Liefert den neuen Modus, oder None wenn keine Modusaenderung ansteht."""
if mode == "off":
Expand Down Expand Up @@ -277,9 +304,10 @@ def main() -> int:
# Aktive Ueberwachung: der Hook darf den Modus selbst schalten.
target = _compute_transition(mode, stage)
if target is not None:
previous_sparmodus_state = sparmodus_state
if target == "__RECOVER__":
recover_to = sparmodus_state.get("prior_mode") or "off"
sparmodus_state = {
sparmodus_state = _preserve_park_fields(previous_sparmodus_state, {
"mode": recover_to,
"prior_mode": None,
"set_at": time.strftime("%Y-%m-%d %H:%M"),
Expand All @@ -288,20 +316,21 @@ def main() -> int:
"wake_at": None,
"resets_at": None,
"leader_only": None,
}
})
pending_hint = _pending_resume_hint(sparmodus_state)
message = (
f"AUTOMATISCHE RUECKKEHR aus NOTAUS (5h-Stand {used_pct:.0f}%, "
f"erholt) -> neuer Modus '{recover_to}'. Fuehre JETZT Abschnitt "
"'Aufheben' aus Skill notaus aus: notaus_state-Vermerk ist "
"bereits gesetzt, aber USMC-RESUME lesen+fortschreiben, "
"erreichbare Teammates per SendMessage reaktivieren und "
"Normalbetrieb an den User melden bleiben Aufgabe dieser "
"Session."
f"Session.{pending_hint}"
)
elif target == "notaus":
wake_at = float(resets_at) if isinstance(resets_at, (int, float)) and resets_at else time.time() + FIVE_HOUR_SECONDS
leader_only = not (isinstance(resets_at, (int, float)) and resets_at)
sparmodus_state = {
sparmodus_state = _preserve_park_fields(previous_sparmodus_state, {
"mode": "notaus",
"prior_mode": mode,
"set_at": time.strftime("%Y-%m-%d %H:%M"),
Expand All @@ -310,7 +339,7 @@ def main() -> int:
"wake_at": wake_at,
"resets_at": resets_at if isinstance(resets_at, (int, float)) else None,
"leader_only": leader_only,
}
})
wake_hint = (
f"Aufwachen um {time.strftime('%Y-%m-%d %H:%M', time.localtime(wake_at))} lokal"
if not leader_only
Expand All @@ -324,7 +353,9 @@ def main() -> int:
"State bereits gesetzt."
)
elif target == "off":
sparmodus_state = _default_sparmodus_state()
sparmodus_state = _preserve_park_fields(
previous_sparmodus_state, _default_sparmodus_state()
)
sparmodus_state["set_at"] = time.strftime("%Y-%m-%d %H:%M")
sparmodus_state["set_by"] = "hook"
sparmodus_state["reason"] = f"Automatisches Ende manual-spar, 5h-Stand {used_pct:.0f}% (erholt)"
Expand Down
82 changes: 82 additions & 0 deletions tests/test_notaus_state_preservation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

import importlib.util
import io
import json
import sys
import time
from pathlib import Path

REPO = Path(__file__).resolve().parents[1]


def _load_library_module(name: str, filename: str):
path = REPO / "library" / filename
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


def _write(path: Path, value: dict) -> None:
path.write_text(json.dumps(value), encoding="utf-8")


def test_budget_recovery_preserves_park_markers_and_reports_pending_resume(tmp_path, monkeypatch, capsys):
guard = _load_library_module("token_budget_guard_test", "token_budget_guard.py")
bridge = tmp_path / "token_budget.json"
state = tmp_path / "sparmodus_state.json"
guard_state = tmp_path / "token_budget_guard_state.json"
config = tmp_path / "token_budget_config.json"

_write(bridge, {"written_at": time.time(), "five_hour": {"used_percentage": 0}})
_write(
state,
{
"mode": "notaus",
"prior_mode": "off",
"paused_goals": ["goal-1", "goal-2"],
"paused_agents": [{"name": "worker-1"}],
},
)
_write(guard_state, {})
_write(config, {})

monkeypatch.setattr(guard, "BRIDGE_PATH", str(bridge))
monkeypatch.setattr(guard, "SPARMODUS_STATE_PATH", str(state))
monkeypatch.setattr(guard, "GUARD_STATE_PATH", str(guard_state))
monkeypatch.setattr(guard, "CONFIG_PATH", str(config))
monkeypatch.setattr(sys, "stdin", io.StringIO('{"session_id":"test-session"}'))

assert guard.main() == 0

written = json.loads(state.read_text(encoding="utf-8"))
assert written["mode"] == "off"
assert written["paused_goals"] == ["goal-1", "goal-2"]
assert written["paused_agents"] == [{"name": "worker-1"}]
assert "Aufhebung ausstehend" in capsys.readouterr().out


def test_wake_check_reports_preserved_park_markers(tmp_path, monkeypatch, capsys):
wake = _load_library_module("notaus_wake_check_test", "notaus_wake_check.py")
state = tmp_path / "sparmodus_state.json"
_write(
state,
{
"mode": "notaus",
"wake_at": 0,
"prior_mode": "off",
"paused_goals": ["goal-1"] * 18,
"paused_agents": [{"name": "worker-1"}] * 3,
},
)
monkeypatch.setattr(wake, "SPARMODUS_STATE_PATH", str(state))
monkeypatch.setattr(sys, "stdin", io.StringIO("{}"))

assert wake.main() == 0

output = capsys.readouterr().out
assert "Aufhebung ausstehend" in output
assert "18 pausierte Goals" in output
assert "3 geparkte Worker" in output
Loading