mirror of
https://github.com/Strategic-Automation/violin.git
synced 2026-08-14 12:33:37 +02:00
356 lines
14 KiB
Python
356 lines
14 KiB
Python
"""Doc-sync + heartbeat state machine for the Violin guard.
|
|
|
|
Single source of truth for the "update your tracking artifacts after every
|
|
command" enforcement and the periodic coarse review. Used by both the core
|
|
``violin_guard.py check-command`` path and the violin_guard plugin, so the
|
|
enforcement is identical no matter which entry point the LLM uses.
|
|
|
|
State files live under ``<eng_dir>/state/``:
|
|
.violin_last_check.json - last approved command (continuity)
|
|
.violin_pending_sync.json - a command was approved but its artifacts
|
|
(ptt.md / history.md / hypothesis-board.md)
|
|
have not yet been verified fresh
|
|
.violin_heartbeat.json - command + message counters
|
|
.violin_heartbeat_pending.json - a periodic coarse review is due
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
# Cadence. A doc-sync gate fires after *every* approved target command; a
|
|
# heartbeat (full engagement-file review) fires every COMMAND_INTERVAL commands
|
|
# or every MESSAGE_INTERVAL messages.
|
|
COMMAND_INTERVAL = 5
|
|
MESSAGE_INTERVAL = 10
|
|
|
|
# How many times the exact same command may be re-issued before check-command
|
|
# hard-blocks it and forces the LLM to stop retrying and do research instead.
|
|
RETRY_LIMIT = 3
|
|
|
|
# A pending-sync lock older than this many hours is treated as stale — almost
|
|
# certainly a leftover from a *prior* session that approved a command, ran it,
|
|
# recorded history, but died before calling sync-done. Auto-expire it so a
|
|
# brand-new session is never wedged by a stale lock (root-cause fix, issue 3).
|
|
# 12h comfortably spans an active session while expiring next-day leftovers.
|
|
PENDING_SYNC_TTL_HOURS = 12
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# state dir / paths
|
|
# --------------------------------------------------------------------------- #
|
|
def state_dir(eng_dir: str) -> Path:
|
|
p = Path(eng_dir) / "state"
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
return p
|
|
|
|
|
|
def _last_check_path(eng_dir: str) -> Path:
|
|
return state_dir(eng_dir) / ".violin_last_check.json"
|
|
|
|
|
|
def _pending_sync_path(eng_dir: str) -> Path:
|
|
return state_dir(eng_dir) / ".violin_pending_sync.json"
|
|
|
|
|
|
def _heartbeat_count_path(eng_dir: str) -> Path:
|
|
return state_dir(eng_dir) / ".violin_heartbeat.json"
|
|
|
|
|
|
def _heartbeat_pending_path(eng_dir: str) -> Path:
|
|
return state_dir(eng_dir) / ".violin_heartbeat_pending.json"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# last approved command (continuity)
|
|
# --------------------------------------------------------------------------- #
|
|
def record_ok_check(eng_dir: str, command: str, phase: str) -> None:
|
|
_last_check_path(eng_dir).write_text(json.dumps({
|
|
"command": command,
|
|
"phase": phase,
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
}))
|
|
|
|
|
|
def last_ok_check(eng_dir: str) -> dict | None:
|
|
p = _last_check_path(eng_dir)
|
|
if not p.exists():
|
|
return None
|
|
try:
|
|
return json.loads(p.read_text())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# DOC-SYNC GATE
|
|
# --------------------------------------------------------------------------- #
|
|
def mark_pending_sync(eng_dir: str, command: str, phase: str) -> None:
|
|
"""Called after a command is approved & returned to the operator."""
|
|
_pending_sync_path(eng_dir).write_text(json.dumps({
|
|
"command": command,
|
|
"phase": phase,
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
}))
|
|
|
|
|
|
def clear_pending_sync(eng_dir: str) -> None:
|
|
p = _pending_sync_path(eng_dir)
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
|
|
def _pending_ts(rec: dict) -> float:
|
|
"""Parse a pending record's ISO-8601 ``ts`` to a UTC epoch, or -1 if unparseable."""
|
|
s = (rec or {}).get("ts", "")
|
|
if not s:
|
|
return -1.0
|
|
try:
|
|
parsed = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.timestamp()
|
|
except Exception:
|
|
return -1.0
|
|
|
|
|
|
def force_clear_pending_sync(eng_dir: str) -> bool:
|
|
"""Unconditionally clear a pending-sync lock.
|
|
|
|
Used for manual reconciliation and at session start (scoping bootstrap calls
|
|
``sync-clear``) to drop a leftover lock from a previous session that would
|
|
otherwise wedge the new session (root-cause fix, issue 3).
|
|
"""
|
|
p = _pending_sync_path(eng_dir)
|
|
if p.exists():
|
|
try:
|
|
p.unlink()
|
|
return True
|
|
except OSError:
|
|
return False
|
|
return False
|
|
|
|
|
|
def has_pending_sync(eng_dir: str) -> dict | None:
|
|
"""Return the pending record if a prior command's artifacts are un-synced.
|
|
|
|
ROOT-CAUSE FIX (issue 2): self-heals truly orphaned locks WITHOUT breaking
|
|
the normal pending flow.
|
|
|
|
Normal flow: a command is approved, ``mark_pending_sync`` arms the lock, THEN
|
|
the LLM runs it and calls ``record-history`` — so for a brief, correct window
|
|
the pending command is NOT yet in history.md. Clearing the lock in that window
|
|
would destroy the doc-sync enforcement, so a missing-from-history command is
|
|
treated as *genuinely pending* (the artifacts_are_fresh gate then decides).
|
|
|
|
The lock is only auto-healed (cleared -> None) when it is unambiguously
|
|
orphaned/stale:
|
|
- the lock file is corrupt/unreadable (can never gate correctly), OR
|
|
- state/history.md does not exist at all, meaning NOTHING was ever run in
|
|
this engagement tree — exactly the incident case (a prior session's lock
|
|
survived into a tree that was never executed). With no history, the lock
|
|
can only be a leftover and would otherwise wedge every later session.
|
|
"""
|
|
p = _pending_sync_path(eng_dir)
|
|
if not p.exists():
|
|
return None
|
|
try:
|
|
rec = json.loads(p.read_text())
|
|
except Exception:
|
|
# Unreadable lock is treated as stale -> clear and unblock.
|
|
try:
|
|
p.unlink()
|
|
except OSError:
|
|
pass
|
|
return None
|
|
hist = Path(eng_dir) / "state" / "history.md"
|
|
if not hist.exists():
|
|
# No history artifact at all -> nothing was ever run for this
|
|
# engagement tree -> the pending command was released but never
|
|
# executed. The lock is a leftover (the incident case) and would
|
|
# otherwise wedge every later session. Clear it.
|
|
try:
|
|
p.unlink()
|
|
except OSError:
|
|
pass
|
|
return None
|
|
# TTL auto-expire: a lock older than PENDING_SYNC_TTL_HOURS is a leftover
|
|
# from a prior session (command recorded in history but sync-done never
|
|
# called). Expire it so a fresh session is not wedged.
|
|
age = datetime.now(timezone.utc).timestamp() - _pending_ts(rec)
|
|
if _pending_ts(rec) > 0 and age > PENDING_SYNC_TTL_HOURS * 3600:
|
|
try:
|
|
p.unlink()
|
|
except OSError:
|
|
pass
|
|
return None
|
|
return rec
|
|
|
|
|
|
def artifacts_are_fresh(eng_dir: str, pending: dict) -> bool:
|
|
"""Verify the tracking artifacts were updated AFTER the pending command ts.
|
|
|
|
Rules:
|
|
- state/history.md MUST contain the command string (continuity proven).
|
|
- ptt.md MUST have a 'Last updated:' timestamp >= pending ts.
|
|
- if phase in {vuln-research, exploitation}: hypothesis-board.md MUST have
|
|
an 'Updated:' timestamp >= pending ts.
|
|
Returns True only if all applicable checks pass.
|
|
"""
|
|
from datetime import datetime as _dt
|
|
|
|
def _ts(s: str) -> float:
|
|
# Normalise every timestamp to an explicit-UTC, tz-aware value so the
|
|
# comparison is consistent regardless of how it was written:
|
|
# - pending ts: "2026-07-08T19:23:49.691262+00:00" (ISO, UTC)
|
|
# - ptt footer: "*Last updated: 2026-07-08 19:29 UTC*"
|
|
# - history: "- [2026-07-08T19:29:15Z] ..."
|
|
# - LLM manual: "2026-07-08 19:25" (local wall-clock)
|
|
# We convert " UTC"/"Z" to "+00:00" and, for bare local wall-clock
|
|
# stamps, assume UTC (the operator's clock) so the pending/artifact
|
|
# clocks are compared on the same basis.
|
|
s = (s or "").strip()
|
|
if not s:
|
|
return -1.0
|
|
# 1) ISO 8601 with optional offset / Z / fractional seconds
|
|
# e.g. "2026-07-08T19:40:15.760831+00:00", "2026-07-08T19:29:15Z".
|
|
try:
|
|
parsed = _dt.fromisoformat(s.replace("Z", "+00:00"))
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.timestamp()
|
|
except Exception:
|
|
pass
|
|
# 2) Plain wall-clock with a " UTC" marker, e.g. "2026-07-08 19:32 UTC".
|
|
s2 = re.sub(r"\bUTC\b", "", s).strip()
|
|
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S"):
|
|
try:
|
|
parsed = _dt.strptime(s2, fmt)
|
|
return parsed.replace(tzinfo=timezone.utc).timestamp()
|
|
except Exception:
|
|
continue
|
|
# 3) Unparseable / placeholder stamp (e.g. "<YYYY-MM-DD HH:MM>") is
|
|
# treated as STALE, never "fresh".
|
|
return -1.0
|
|
|
|
# Strip markdown wrapping (*, **, - ) from a "*Last updated: ...*" style line
|
|
# and return the bare label (lower) + value, or (None, None) if not a
|
|
# "last updated"/"updated" field.
|
|
_FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?\**\s*(last updated|updated)\s*[:*]\s*\**\s*(.*?)\s*\**\s*$",
|
|
re.IGNORECASE)
|
|
|
|
d = Path(eng_dir)
|
|
pending_ts = _ts(pending.get("ts", ""))
|
|
# Artifacts are stamped at minute/second resolution (e.g. record-ptt writes
|
|
# "%Y-%m-%d %H:%M UTC"), while the pending ts carries microsecond
|
|
# resolution. Comparing directly would make a same-minute update look
|
|
# stale, so we floor the pending ts to the minute for the freshness check.
|
|
pending_min = pending_ts - (pending_ts % 60)
|
|
# 1) history continuity
|
|
hist = d / "state" / "history.md"
|
|
if not (hist.exists() and pending.get("command", "") in hist.read_text(encoding="utf-8", errors="ignore")):
|
|
return False
|
|
# 2) ptt freshness (deployed at state/ptt.md)
|
|
ptt = d / "state" / "ptt.md"
|
|
if ptt.exists():
|
|
freshest = 0.0
|
|
matched = False
|
|
for line in ptt.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
m = _FIELD_RE.match(line)
|
|
if m and m.group(1).lower() == "last updated":
|
|
matched = True
|
|
freshest = max(freshest, _ts(m.group(2)))
|
|
if not matched or freshest < pending_min:
|
|
return False
|
|
# 3) hypothesis board freshness (research/exploitation phases)
|
|
# deployed at hypotheses.md (top-level)
|
|
if pending.get("phase") in ("vuln-research", "exploitation"):
|
|
hb = d / "hypotheses.md"
|
|
if hb.exists():
|
|
freshest = 0.0
|
|
matched = False
|
|
for line in hb.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
m = _FIELD_RE.match(line)
|
|
if m and m.group(1).lower() == "updated":
|
|
matched = True
|
|
freshest = max(freshest, _ts(m.group(2)))
|
|
if not matched or freshest < pending_min:
|
|
return False
|
|
return True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# HEARTBEAT GATE
|
|
# --------------------------------------------------------------------------- #
|
|
def _read_counts(eng_dir: str) -> dict:
|
|
p = _heartbeat_count_path(eng_dir)
|
|
if p.exists():
|
|
try:
|
|
return json.loads(p.read_text())
|
|
except Exception:
|
|
pass
|
|
return {"command_count": 0, "message_count": 0}
|
|
|
|
|
|
def tick_command(eng_dir: str) -> int:
|
|
"""Increment the approved-command counter; return the new count."""
|
|
c = _read_counts(eng_dir)
|
|
c["command_count"] = c.get("command_count", 0) + 1
|
|
_heartbeat_count_path(eng_dir).write_text(json.dumps(c))
|
|
return c["command_count"]
|
|
|
|
|
|
def tick_message(eng_dir: str) -> int:
|
|
"""Increment the message counter (LLM calls this per message); return new count."""
|
|
c = _read_counts(eng_dir)
|
|
c["message_count"] = c.get("message_count", 0) + 1
|
|
_heartbeat_count_path(eng_dir).write_text(json.dumps(c))
|
|
return c["message_count"]
|
|
|
|
|
|
def set_heartbeat_pending(eng_dir: str, reason: str) -> None:
|
|
_heartbeat_pending_path(eng_dir).write_text(json.dumps({
|
|
"reason": reason,
|
|
"skill_review_required": True,
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
}))
|
|
|
|
|
|
def has_heartbeat_pending(eng_dir: str) -> dict | None:
|
|
p = _heartbeat_pending_path(eng_dir)
|
|
if not p.exists():
|
|
return None
|
|
try:
|
|
return json.loads(p.read_text())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def clear_heartbeat_pending(eng_dir: str) -> None:
|
|
p = _heartbeat_pending_path(eng_dir)
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# STUCK / RETRY DETECTION
|
|
# --------------------------------------------------------------------------- #
|
|
def repeat_count(eng_dir: str, command: str) -> int:
|
|
"""Count exact occurrences of ``command`` in state/history.md.
|
|
|
|
Used by check-command to block retry loops: re-issuing the same command
|
|
over and over is the classic "stuck" anti-pattern. Returns 0 if history is
|
|
absent.
|
|
"""
|
|
hist = Path(eng_dir) / "state" / "history.md"
|
|
if not hist.exists():
|
|
return 0
|
|
needle = command.strip()
|
|
if not needle:
|
|
return 0
|
|
text = hist.read_text(encoding="utf-8", errors="ignore")
|
|
return text.count(needle)
|