PRODUCTAGENTharrisonhjohnson/productagent · /harnesses/todo/bot/ventures_signal.py
000%

ventures_signal.py

view on github ↗314 lines · python
"""
Ventures signal — the PM system's Navi transport (ventures standup #8 accord, 2026-08-24).

Navi carries SIGNALS from ~/ventures (lane states, queue, alerts) and executes the
bounded verbs — ack / pause / resume / decide / standup-request — as the operator's own
unfenced process. It never holds ventures state and never runs the meeting.

Contract (pm/LOG.md, 2026-08-24 evening entry): one machine, same gates, any channel —
every action here lands in a ventures file, dated, marked "via Navi". Orders and TODOs
stay separate systems.
"""

import hashlib
import json
import logging
import os
import re
from datetime import date, datetime
try:
    from config import USER_NAME
except ImportError:  # pragma: no cover
    USER_NAME = "you"

logger = logging.getLogger(__name__)

_STATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ventures_state.json")

LANES = ("night", "day")


def _root():
    try:
        import config
        root = getattr(config, "VENTURES_ROOT", None)
    except Exception:
        root = None
    return root or os.path.expanduser("~/ventures")


def _lane_dir(lane):
    return os.path.join(_root(), "00-ops", lane)


def _now():
    return datetime.now().strftime("%Y-%m-%d %H:%M")


# ---- readers ---------------------------------------------------------------

def read_lane(lane):
    """One lane's run-state + standing alert. Never raises."""
    out = {"lane": lane, "state": None, "alert": None}
    try:
        with open(os.path.join(_lane_dir(lane), "run-state.json")) as f:
            out["state"] = json.load(f)
    except Exception:
        pass
    try:
        alert_path = os.path.join(_lane_dir(lane), "ALERT.md")
        if os.path.isfile(alert_path):
            with open(alert_path) as f:
                lines = f.read().strip().splitlines()
            out["alert"] = lines[2] if len(lines) > 2 else (lines[0] if lines else "(empty)")
    except Exception:
        pass
    return out


def pending_orders():
    """Count unchecked '- [ ]' lines in ORDERS.md '## Tonight' section (mirrors the
    night wrapper's awk). Returns int or None on read failure."""
    try:
        with open(os.path.join(_root(), "pm", "ORDERS.md")) as f:
            lines = f.read().splitlines()
        n, inside = 0, False
        for line in lines:
            if line.startswith("## Tonight"):
                inside = True
                continue
            if re.match(r"^## ", line):
                inside = False
            if inside and line.startswith("- [ ]"):
                n += 1
        return n
    except Exception:
        return None


def capture_info():
    out = {"last_ac": None, "paused": False}
    cap = os.path.join(_root(), "00-ops", "capture")
    try:
        with open(os.path.join(cap, "last-ac")) as f:
            out["last_ac"] = f.read().strip()
    except Exception:
        pass
    out["paused"] = os.path.isfile(os.path.join(cap, "PAUSED"))
    return out


def _day_word(iso_date):
    try:
        d = date.fromisoformat(iso_date)
    except Exception:
        return iso_date or "never"
    delta = (date.today() - d).days
    return {0: "today", 1: "yesterday"}.get(delta, f"{iso_date} ({delta}d ago)")


def _lane_line(info):
    s = info["state"]
    if not s:
        return f"  {info['lane']}: no state file"
    cost = s.get("cost_usd", 0)
    return (f"  {info['lane']}: {s.get('status', '?')} {_day_word(s.get('date', ''))}"
            f" (${cost:.2f})" if isinstance(cost, (int, float)) else
            f"  {info['lane']}: {s.get('status', '?')} {_day_word(s.get('date', ''))}")


# ---- renderer --------------------------------------------------------------

def render_section(kind="status"):
    """Message lines for the ventures signal. kind: 'morning' | 'evening' | 'status'.
    Returns [] only on total failure — callers may append directly."""
    try:
        lanes = [read_lane(l) for l in LANES]
        cap = capture_info()
        q = pending_orders()
        alerts = [i for i in lanes if i["alert"]]

        header = {"morning": "🏭 VENTURES — dawn read-back:",
                  "evening": "🏭 VENTURES — dusk queue check:",
                  "status": "🏭 VENTURES:"}[kind]
        parts = [header]
        if cap["paused"]:
            parts.append("  ⏸ capture PAUSED (resume with: /ventures resume)")
        for i in lanes:
            parts.append(_lane_line(i))
        for i in alerts:
            parts.append(f"  🔴 {i['lane']} ALERT standing: {i['alert']}")
            parts.append(f"     ack with: /ventures ack {i['lane']}")

        if kind == "evening":
            if q is None:
                parts.append("  queue: ORDERS.md unreadable")
            elif q == 0:
                parts.append("  queue: empty for tonight (a valid answer)")
            else:
                parts.append(f"  queue: {q} order(s) armed for the next capture window")
            if alerts:
                parts.append("  ⚠️ queue contract: no NEW orders while an alert stands — read back first")
        elif kind == "morning":
            bad = [i for i in lanes if i["state"] and i["state"].get("status")
                   not in ("ok", "no-orders")]
            if alerts or bad:
                parts.append("  → read-back due: convene standup at the desk")
            else:
                parts.append("  → both lanes clean; nothing to decide is a complete meeting")
        else:  # status
            if q is not None:
                parts.append(f"  queue: {q} unchecked Tonight order(s)")
            if cap["last_ac"]:
                parts.append(f"  last AC window: {_day_word(cap['last_ac'])}")
        return parts
    except Exception as e:
        logger.error(f"ventures render failed: {e}")
        return []


# ---- immediate alert relay -------------------------------------------------

def _load_seen():
    try:
        with open(_STATE_PATH) as f:
            return json.load(f)
    except Exception:
        return {}


def _save_seen(seen):
    try:
        with open(_STATE_PATH, "w") as f:
            json.dump(seen, f)
    except Exception as e:
        logger.error(f"ventures state save failed: {e}")


def scan_alerts():
    """Return push strings for new/changed ALERT.md files; dedup persisted across
    restarts. An alert deleted (acked) and later rewritten pushes again."""
    pushes = []
    seen = _load_seen()
    changed = False
    for lane in LANES:
        path = os.path.join(_lane_dir(lane), "ALERT.md")
        key = f"alert:{lane}"
        try:
            if os.path.isfile(path):
                with open(path) as f:
                    content = f.read()
                digest = hashlib.sha1(content.encode()).hexdigest()
                if seen.get(key) != digest:
                    body = "\n".join(content.strip().splitlines()[:12])
                    pushes.append(f"🔴 VENTURES {lane.upper()} ALERT\n\n{body}\n\n"
                                  f"ack with: /ventures ack {lane}")
                    seen[key] = digest
                    changed = True
            elif key in seen:
                del seen[key]
                changed = True
        except Exception as e:
            logger.error(f"ventures alert scan ({lane}): {e}")
    if changed:
        _save_seen(seen)
    return pushes


# ---- bounded verbs ---------------------------------------------------------

def ack(lane):
    if lane not in LANES:
        return f"Unknown lane '{lane}'. Lanes: night, day."
    path = os.path.join(_lane_dir(lane), "ALERT.md")
    if not os.path.isfile(path):
        return f"No standing {lane} alert."
    try:
        with open(path) as f:
            lines = f.read().strip().splitlines()
        headline = lines[2] if len(lines) > 2 else "(unreadable)"
        log_path = os.path.join(_lane_dir(lane), "logs", f"{date.today().isoformat()}.log")
        os.makedirs(os.path.dirname(log_path), exist_ok=True)
        with open(log_path, "a") as f:
            f.write(f"[navi] {_now()} ALERT acknowledged by {USER_NAME} via Telegram"
                    f" — was: {headline}\n")
        os.remove(path)
        return f"Acked {lane} alert: {headline}\n(logged to the lane's daily log)"
    except Exception as e:
        return f"Ack failed: {e}"


def pause():
    path = os.path.join(_root(), "00-ops", "capture", "PAUSED")
    try:
        with open(path, "w") as f:
            f.write(f"paused by {USER_NAME} via Navi {_now()}\n")
        return "⏸ Capture paused — no lane fires until /ventures resume."
    except Exception as e:
        return f"Pause failed: {e}"


def resume():
    path = os.path.join(_root(), "00-ops", "capture", "PAUSED")
    try:
        if os.path.isfile(path):
            os.remove(path)
            return "▶️ Capture resumed — next poll may fire within 10 min if on AC."
        return "Capture wasn't paused."
    except Exception as e:
        return f"Resume failed: {e}"


def decide(text):
    """Append a charter-defined directive line to pm/LOG.md — the next standup applies
    it and notes the change (pm/CHARTER.md defines this mechanism)."""
    if not text.strip():
        return "Usage: /ventures decide <one-line decision>"
    try:
        log_path = os.path.join(_root(), "pm", "LOG.md")
        with open(log_path) as f:
            lines = f.read().splitlines(keepends=False)
        directive = f"> directive: {text.strip()} ({USER_NAME} via Navi, {_now()})"
        for i, line in enumerate(lines):
            if line.strip() == "---":
                lines.insert(i + 1, "")
                lines.insert(i + 2, directive)
                break
        else:
            lines.append(directive)
        with open(log_path, "w") as f:
            f.write("\n".join(lines) + "\n")
        return f"Pinned for the next standup:\n{directive}"
    except Exception as e:
        return f"Decide failed: {e}"


def standup_request():
    msg = decide("convene standup — requested via Navi")
    return (f"{msg}\n\nThe meeting runs at the desk by default (evidence gets graded "
            f"against diffs there). From the phone: open a desk tab in navi-mobile "
            f"mode and type 'standup' — flagged as a Navi-driven meeting.")


# ---- command surface -------------------------------------------------------

def handle_ventures_command(text, chat_id, user_id):
    """'/ventures [status|ack <lane>|pause|resume|decide <text>|standup]' -> reply str."""
    parts = text.split()
    sub = parts[1].lower() if len(parts) > 1 else "status"
    if sub == "status":
        return "\n".join(render_section("status")) or "ventures signal unavailable"
    if sub == "ack":
        if len(parts) < 3:
            return "Usage: /ventures ack night|day"
        return ack(parts[2].lower())
    if sub == "pause":
        return pause()
    if sub == "resume":
        return resume()
    if sub == "decide":
        return decide(text.split(None, 2)[2] if len(parts) > 2 else "")
    if sub == "standup":
        return standup_request()
    return ("Unknown ventures command. Available: status · ack night|day · pause · "
            "resume · decide <text> · standup")
flow-designglobal
SKILL.md74 lines
SKILL.md162 lines
pm-strategistglobal
pm-strategist.md51 lines
todosystem
bot31 items
agent_executor.py344 lines
behavior_state.py106 lines
BOT.md175 lines
config.py76 lines
git_sync.py87 lines
hyrule_server.py284 lines
manage_bot.sh47 lines
mcp_server.py324 lines
mobile_mode.py229 lines
navi_tab.py95 lines
prework_engine.py426 lines
run_bot.sh18 lines
run_hyrule.sh38 lines
scheduler.py958 lines
session.py264 lines
telegram_bot.py3328 lines
todo_manager.py1130 lines
ventures_signal.py314 lines
voice_handler.py203 lines
VOICE_SETUP.md112 lines
warroom_manager.py1004 lines
README.md44 lines
night-orderssystem
README.md84 lines
loops-spec.md212 lines
settings.json116 lines
README.md59 lines