#!/usr/bin/env python3 """wa_drill.py — the diary sergeant, v2 (evidence-templated, pooled, budgeted, tapering). ROCKY'S RULING (2026-07-20): the bot drills the crew morning/midday/knock-off about USING the diary — posting, explaining, reporting — NEVER about performance. Plus his second steer, same day: vary the bollocking or "they just think it's automation and it's not actually keeping an eye on them". V2 DESIGN (docs/DIARY-SERGEANT-DESIGN.md §2-3): the watched feeling comes from EVIDENCE, not fresh insults — - FACTS FROM CODE, never a model: {n_photos} {n_words} {last_time} {hours_quiet} computed off the day-log. Pure counts. Can't be wrong, can't editorialise. - SKELETON POOLS in Rocky's voice: drill_.txt, ONE LINE PER SKELETON (a single-line file still works — pool of one). Rotation is deterministic no-repeat: (day-of-year + job) picks the line, never the same one two days running. NO model call exists in this script — it is structurally incapable of commenting on a man's work. - MESSAGE BUDGET: max 4 bot messages per group per day ACROSS ALL RAILS (counted from the bridge's own SENT log — the only honest send record). A group that's had its 4 is left alone. Command (mgmt) chats are a different lane and never drilled at all. - EARNED QUIET: a group with crew words on >=4 of the trailing 6 workdays drops to knock-off-only; slips, and full drilling returns. Computed, logged, no bookkeeping. FIRES ON ABSENCE ONLY (unchanged from v1): morning = day-log empty; midday = NOTHING at all since 10:30 (photos count — drilling over live photos is a false accusation, caught live on v1's first test day); knockoff = no human WORDS since 14:00 (the report is words; photos alone don't discharge it). Pour-day knock-off uses drill_pour.txt if a docket landed and the pool file exists. A "rained off, not on" reply at 07:00 is the day's diary entry and silences the rest. DEFAULT ON (drill_on); kill: rm drill_on. WA_DRY honoured via queue_question. Every decision logged DRILL/SKIP with the reason. SITE DIARY MODE (diary_mode flag, Rocky's ruling 2026-07-23): tell, don't ask. The bot is the job's SITE DIARY, not an interrogator — - morning/midday become REMINDERS (diary_morning.txt / diary_midday.txt pools, his voice), same absence-fire + nobody-there gate + budget as the drill; - knockoff nag RETIRES; a 17:30 `report` slot posts the day's diary INTO the group: computed header (times/counts/names — code, not model) + the write-up's own GAPS list + the Drive link + "boss: yep or correct it". The foreman's reply is a group message, so the verification lands in the day-log by itself. The gaps printing LOUD is the training: feed the diary all day and the report is clean; starve it and you're typing at knock-off (Rocky: "subconsciously it'll train the person that uses it"). - `intro` slot (manual, run once per flip): posts the standing what-I-am message (diary_intro.txt) to every job group. Both flags off = silent. diary_mode and drill_on are exclusive personalities; if both are present diary_mode wins (the sergeant retires, doesn't double up). Usage: python3 wa_drill.py morning|midday|knockoff|report|intro """ import sys, os, re, json, datetime, subprocess BASE = "/root/.hermes/wa-bridge" ON_FLAG = os.path.join(BASE, "drill_on") DIARY_FLAG = os.path.join(BASE, "diary_mode") GROUP_MAP = os.environ.get("MAP_FILE", os.path.join(BASE, "group_map.json")) DAYS_DIR = os.environ.get("DAYS_DIR", os.path.join(BASE, "days")) BRIDGE_LOG = os.environ.get("BRIDGE_LOG", "/root/.hermes/logs/wa_bridge.log") STATE_FILE = os.path.join(DAYS_DIR, "drill_state.json") BUDGET = 4 # bot messages per group per day, all rails (doc ruling rec) QUIET_DAYS = 4 # word-days out of trailing 6 workdays that earn knock-off-only LOOKBACK_WORKDAYS = 6 ANY_KINDS = ("text", "audio", "photo", "document") WORD_KINDS = ("text", "audio") SLOTS = { "morning": (None, ANY_KINDS), "midday": ("10:30", ANY_KINDS), "knockoff": ("14:00", WORD_KINDS), } DEFAULT_POOL = { "morning": ["Diary's empty lads. Who's on and what are ye at? Post it up."], "midday": ["Nothing in the group since {last_time}, lads. What's happening out there? One line."], "knockoff": ["Knock-off report lads: who did what, rough metres or loads. Don't leave it empty."], } # Site Diary voice (diary_mode). Box-editable: diary_.txt overrides each entry, one # line per skeleton, same rotation as the drill pools. Pools were 1-3 lines at launch and # the crew clocked the repetition inside two days (Rocky, 25/07: "everybody was taking the # piss... we can't have it saying the same things") — repertoire lives in pools/ in the # repo, 60+ lines authored in his voice, HE culls, send stays deterministic. Variety is # authored offline and human-gated, never rolled live at the crew. DIARY_DEFAULT_POOL = { "morning": ["Site Diary's on. Who's on, start times, what's the plan - drop it in here as ye go. Photos with a quick line beats photos alone."], "midday": ["Anything gone sideways since this morning? Variations, delays, materials ye need - put it here now, not at knock-off."], "intro": ["This is the Site Diary. When ye start a job, when ye finish, any variations, any problems, any materials ye need - put it in here. It all gets logged against the job, and the day's diary posts back here every evening."], "report_open": ["Diary's up for {d}: {link}"], "report_close": ["Anyone knows, sing out. Supervisor: yep or fix it here."], "report_clean": ["Nothing missing. Good day's diary lads."], } sys.path.insert(0, BASE) def log(msg): line = "%s %s" % (datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), msg) print(line) try: open("/root/.hermes/logs/wa_capture.log", "a").write(line + "\n") except Exception: pass def pool_for(slot, prefix="drill"): try: lines = [l.strip() for l in open(os.path.join(BASE, "%s_%s.txt" % (prefix, slot))) if l.strip()] if lines: return lines except Exception: pass if prefix == "diary": return DIARY_DEFAULT_POOL.get(slot, DIARY_DEFAULT_POOL["morning"]) return DEFAULT_POOL.get(slot, DEFAULT_POOL["morning"]) def job_groups(): try: gm = json.load(open(GROUP_MAP)) except Exception: return {} return {grp: m for grp, m in gm.items() if m.get("job") and m.get("type", "job") == "job"} def last_inbound(): """When did the bridge last actually RECEIVE something? -> (datetime|None, note). THE FAULT THIS EXISTS FOR (2026-08-04 to 08-06). The WhatsApp session logged out at 17:50 on the 4th. The bridge process stayed up, the port stayed listening, the log kept growing — it wrote `Logged out. Delete session and restart` 15,391 times — and the gateway sat on reconnect attempt 2,288. Every consumer downstream read an empty day-log and reported a quiet day. A whole working day was lost and it surfaced 39 hours later only because Rocky pasted the group into a chat by hand. THE POINT, and it is bigger than this script: an empty day-log means NOTHING on its own. "Nobody posted" and "the rail is deaf" produce byte-identical evidence, so a consumer that reports one without checking the other is guessing. This is the check that tells them apart, and it belongs at the moment of reporting, not in a health sweep nobody runs. Same class as the 24/07 zero-byte writeup and the upsert helper that never ran: a dead thing and a quiet thing look the same until something asserts liveness out loud. UNKNOWN IS NEVER HEALTHY. If the log is missing or unparseable this returns None, and every caller must treat None as "cannot vouch for the rail", never as "fine". """ if not os.path.exists(BRIDGE_LOG): return None, "bridge log not found at %s" % BRIDGE_LOG last = None try: # Tail-ish: the log runs to tens of thousands of lines, and only inbound lines count. # SENT lines are OUR traffic — they prove nothing about whether we can HEAR. with open(BRIDGE_LOG, encoding="utf-8", errors="ignore") as fh: for line in fh: if " GROUP " in line or " MEDIA " in line: last = line[:24] except Exception as e: return None, "bridge log unreadable: %s" % e if not last: return None, "no inbound line in the bridge log at all" try: ts = datetime.datetime.strptime(last[:19], "%Y-%m-%dT%H:%M:%S") return ts.replace(tzinfo=datetime.timezone.utc), "" except Exception: return None, "could not parse a timestamp from %r" % last[:24] def rail_state(for_date): """One line of plain fact about the capture rail, for whoever is about to report a day. Returns (is_deaf, phrase). DEAF means the bridge heard nothing at any point during the day being reported — so that day's emptiness is unexplained, not evidence of a quiet site. No arbitrary hour threshold: the question is simply whether the rail was alive during the window we are describing. """ ts, note = last_inbound() if ts is None: return True, "RAIL UNVERIFIED (%s) — cannot tell a quiet day from a deaf one" % note now = datetime.datetime.now(datetime.timezone.utc) hrs = (now - ts).total_seconds() / 3600.0 seen = ts.astimezone().strftime("%d/%m %H:%M") day_start = datetime.datetime.combine( for_date, datetime.time.min).astimezone() return ts < day_start, "bridge last heard a message %.1f h ago (%s)" % (hrs, seen) def day_rows(job, date): path = os.path.join(DAYS_DIR, "%s_%s.jsonl" % (job, date)) if not os.path.exists(path): return [] out = [] for line in open(path, encoding="utf-8"): try: r = json.loads(line) except ValueError: continue if not r.get("retracted"): out.append(r) return out def facts(rows): """Computed evidence for the skeletons. Counts and clock times only — nothing inferred.""" now = datetime.datetime.now() words = [r for r in rows if r.get("kind") in WORD_KINDS] photos = [r for r in rows if r.get("kind") == "photo"] last_t = max((r.get("t") or "" for r in rows), default="") hours_quiet = "" if last_t: try: h, mi = map(int, last_t.split(":")) hours_quiet = "%.0f" % max(0, (now - now.replace(hour=h, minute=mi)).total_seconds() / 3600) except ValueError: pass return {"n_photos": len(photos), "n_words": len(words), "last_time": last_t or "kickoff", "hours_quiet": hours_quiet or "a few"} def sends_today(jid): """Bot messages already sent to this group today, ALL rails — the bridge's SENT log is the one honest record. Bridge stamps UTC; convert to box-local date.""" if not os.path.exists(BRIDGE_LOG): return 0 offset = datetime.datetime.now() - datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) today = datetime.date.today() n = 0 needle = "SENT -> " + jid for line in open(BRIDGE_LOG, encoding="utf-8", errors="replace"): if needle not in line: continue try: ts = datetime.datetime.fromisoformat(line.split(" ", 1)[0].replace("Z", "")) if (ts + offset).date() == today: n += 1 except ValueError: continue return n def word_days(job, upto): """Workdays (Mon-Sat) in the trailing window that have >=1 crew word row.""" got, seen = 0, 0 d = upto - datetime.timedelta(days=1) while seen < LOOKBACK_WORKDAYS: if d.weekday() != 6: # skip Sundays seen += 1 if any(r.get("kind") in WORD_KINDS for r in day_rows(job, d.isoformat())): got += 1 d -= datetime.timedelta(days=1) return got def pick_line(pool, slot, job, f): """Deterministic no-repeat rotation; unknown placeholders drop the line rather than sending a broken string.""" state = {} try: state = json.load(open(STATE_FILE)) except Exception: pass key = "%s_%s" % (slot, job) idx = (datetime.date.today().timetuple().tm_yday + sum(ord(c) for c in job)) % len(pool) if len(pool) > 1 and idx == state.get(key, -1): idx = (idx + 1) % len(pool) line = None for i in range(len(pool)): try: line = pool[(idx + i) % len(pool)].format(**f) idx = (idx + i) % len(pool) break except (KeyError, IndexError): continue if line is None: fb = (DEFAULT_POOL.get(slot) or DIARY_DEFAULT_POOL.get(slot) or [""])[0] try: line = fb.format(**f) except (KeyError, IndexError): line = fb state[key] = idx try: os.makedirs(DAYS_DIR, exist_ok=True) json.dump(state, open(STATE_FILE, "w")) except Exception: pass return line def build_report(job, date, rows, name): """The 17:30 Site Diary report post (diary_mode). Header is COMPUTED — counts, clock times, names off the day-log, code not model. The GAPS bullets are lifted VERBATIM from the day's write-up, which runs under its own never-invent hard rule — this function never authors a word about the work itself. Gaps print LOUD by design: the report's holes are the training (Rocky, 2026-07-23).""" drive_link = "" try: out = subprocess.run(["python3", os.path.join(BASE, "write_up.py"), job, date], capture_output=True, text=True, timeout=240).stdout m = re.search(r"Drive doc:\s*(https://\S+)", out) if m: drive_link = m.group(1) except Exception as e: log("DIARY report write_up failed for %s: %s" % (job, e)) gaps = [] try: md = open(os.path.join(DAYS_DIR, "%s_%s_writeup.md" % (job, date)), encoding="utf-8").read() m = re.search(r"#+\s*GAPS[^\n]*\n(.*?)(?=\n#|\Z)", md, re.S) if m: gaps = [l.strip() for l in m.group(1).splitlines() if l.strip().startswith("-")][:6] except Exception: pass # BLAME FILTER (2026-07-24, first live report): the gaps are model-written and regenerate # per run — one roll produced "who took it?" and "who's liable?" IN THE CREW GROUP. The # write-up prompt now bans that tone at source; this is the belt to its braces. A gap # aimed at a person gets dropped from the POST (it stays in the Drive doc for the # office), logged so the drift is visible. Deterministic, never rewrites — drops. BLAME = re.compile(r"who('s| is|se)?\s+(took|has|broke|liable|fault|responsib)|liable|whose fault", re.I) kept = [] for g in gaps: if BLAME.search(g): log("DIARY report gap DROPPED (blame tone) %s: %r" % (job, g)) else: kept.append(g) gaps = kept # SHORT AND HUMAN (Rocky 24/07 night: "the boys were extremely taking the piss... # smarten it up massively"). v1 was six sentence-bullets under a formal header with # raw markdown ** rendering as literal asterisks in WhatsApp. Now: four lines max, # gaps as tight fragments, zero markdown, no ceremony. The Drive doc keeps the full # formal record; the group post is a bloke saying the diary's done. def _tight(g): g = g.lstrip("- ").strip().replace("**", "").replace("*", "").replace("`", "") for sep in (" — ", " -- ", " – ", ": ", " - "): if sep in g: g = g.split(sep)[0] break return g.strip().rstrip(".?") + ("?" if "?" in g else "") d, mth = date[8:10].lstrip("0"), date[5:7].lstrip("0") f = {"d": "%s/%s" % (d, mth), "link": drive_link or "(Drive link failed - doc's in the day folder)"} tight = [_tight(g) for g in gaps if _tight(g)][:4] # composed voice first (Rocky 26/07), pools as fallback; the LINK and the GAPS are # assembled by code either way — facts never ride the free text. opener = closer = None try: import wa_chat opener = wa_chat.compose(job, "The day's diary is written and you're posting it to " "the group. ONE short line announcing it — the link gets " "added after your words by the system, so no URL. Fresh " "words, never yesterday's.", maxlen=120) closer = wa_chat.compose(job, "THE REPORT CARD (Rocky's spec 26/07). Score today's " "DIARY out of 10 — you're grading the BOOK, never the men: " "crew+hours stated? start/finish markers? quantities in " "units? delays with times? photos WITH words? tomorrow " "stated? Then: what's missing, collectively (%s), and ask " "whoever knows to sing out + the Supervisor to confirm. " "PRAISE BY NAME where a bloke earned it today. ALL deficits " "stay COLLECTIVE — a TEAM report card, never a league " "table (Rocky's final ruling 26/07: not everyone carries " "the posting duty, so naming can't be fair). The picture " "standard is Liam's policy — push it as 'we/us', firm and " "smart: 'light on pictures today lads, Liam wants them - " "not hard'. 2-4 short lines, fresh words, teach the team." % ("; ".join(tight) if tight else "nothing — clean day, " "score it high and say so"), maxlen=400) except Exception: pass lines = [(opener or pick_line(pool_for("report_open", "diary"), "report_open", job, f) .replace(f["link"], "").rstrip(": ")) + ": " + f["link"]] \ if opener else [pick_line(pool_for("report_open", "diary"), "report_open", job, f)] if tight: lines.append("Still blank: " + " / ".join(tight)) lines.append(closer or pick_line(pool_for("report_close", "diary"), "report_close", job, f)) else: lines.append(closer or pick_line(pool_for("report_clean", "diary"), "report_clean", job, f)) return "\n".join(lines) def main(): slot = sys.argv[1] if len(sys.argv) > 1 else "" if slot not in SLOTS and slot not in ("report", "intro"): print("usage: wa_drill.py morning|midday|knockoff|report|intro") sys.exit(1) diary = os.path.exists(DIARY_FLAG) import process_capture if slot == "intro": # Manual, once per flip: the standing what-I-am message to every job group. if not diary: log("DIARY intro skipped — diary_mode off") return text = pool_for("intro", "diary")[0] for grp, m in sorted(job_groups().items()): process_capture.queue_question(grp + "@g.us", text, long=True) log("DIARY intro -> %s (%s)" % (m["job"], m.get("name", grp))) return if slot == "report": if not diary: log("DIARY report off (touch diary_mode to enable)") return today = datetime.date.today() date = today.isoformat() # Computed ONCE, before any job is judged: was the rail even alive today? An empty # day-log is not evidence of a quiet site until this question has been answered. deaf, rail = rail_state(today) for grp, m in sorted(job_groups().items()): job = m["job"] rows = day_rows(job, date) if not rows: if deaf: # NOT "nothing to report". We do not know that. What we know is that the # rail could not have heard anything, so the day is UNCAPTURED and the # crew may well have worked it. Say the thing that is true. log("🔴 DIARY report %s — DAY UNCAPTURED, RAIL DEAF: %s. " "Day-log empty, but the bridge heard nothing all day, so this is NOT " "evidence of a quiet site. Fix the bridge, then recover the day by " "hand — the messages are still in WhatsApp." % (job, rail)) else: log("DIARY report SKIP %s — empty day-log, nothing to report (%s)" % (job, rail)) continue msg = build_report(job, date, rows, m.get("name", job)) # long lane: the report runs well past the 200-char waffle cap, and over-cap # normal sends are DROPPED silently (found in dry-run, 2026-07-23) process_capture.queue_question(grp + "@g.us", msg, long=True) # The rail fact rides on the success line too. `deaf` cannot fire here — rows # exist, so something was heard today — but a bridge that died at lunchtime # leaves a report that is quietly HALF a day. The hours-quiet number is the only # thing that shows it. log("DIARY report -> %s (%d rows, %d chars) [%s]" % (job, len(rows), len(msg), rail)) return if diary: if slot == "knockoff": log("DIARY knockoff nag retired — the 17:30 report covers it") return prefix = "diary" elif os.path.exists(ON_FLAG): prefix = "drill" else: log("DRILL %s off (touch drill_on or diary_mode to enable)" % slot) return import wa_coach today = datetime.date.today() date = today.isoformat() start, kinds = SLOTS[slot] for grp, m in sorted(job_groups().items()): job = m["job"] rows = day_rows(job, date) # NOBODY HERE = DON'T DRILL (Rocky/Hus, 2026-07-21: "if I dont post a roster here there is # no one here"). A job with no roster AND no hard work evidence (photo/docket) today has # nobody on it — an empty diary is CORRECT, not a crew slacking. Crew TEXT does NOT count as # work: 2636 today was two messages telling the bot to shut up, zero blokes on site, and the # sergeant nagged it morning + midday. Same gate the coach runs; reuses its @-mention roster # scan, no model. Gated on roster_on so the drill is unchanged until the roster feature is on. if os.path.exists(wa_coach.ROSTER_FLAG): prior = day_rows(job, (today - datetime.timedelta(days=1)).isoformat()) roster_found = wa_coach.detect_roster(wa_coach.roster_window(prior, rows)).get("found") has_work = any(r.get("kind") in ("photo", "document") for r in rows) if not roster_found and not has_work: log("DRILL SKIP %s %s — no roster + no site evidence, nobody on this job today" % (slot, job)) continue n = sum(1 for r in rows if r.get("kind") in kinds and (start is None or (r.get("t") or "") >= start)) # absence-fire is SERGEANT law, not lookout law: a lookout must be able to notice # a new number in a BUSY group (Rocky 26/07). Diary passes always run; Spike's own # judgment (incl. NOTHING) replaces the quiet-group precondition. Drill keeps it. if n and prefix != "diary": log("DRILL SKIP %s %s — window has %d row(s) of %s" % (slot, job, n, "/".join(kinds))) continue if prefix != "diary" and slot != "knockoff" and word_days(job, today) >= QUIET_DAYS: log("DRILL SKIP %s %s — earned quiet (%d+ word-days of trailing %d)" % (slot, job, QUIET_DAYS, LOOKBACK_WORKDAYS)) continue jid = grp + "@g.us" sent = sends_today(jid) if sent >= BUDGET: log("DRILL SKIP %s %s — budget spent (%d sends today, cap %d)" % (slot, job, sent, BUDGET)) continue f = facts(rows) # FREE VOICE (Rocky 26/07: "I don't like the rigid responses... it thinks it's a # dumb bot"): in diary mode the WORDS are Spike's, composed fresh per job per fire, # through the same gate as the chat. WHEN it fires stays this code. Pools = fallback # only, so a dead API never silences the rail. if prefix == "diary": # THE LOOKOUT (Rocky 26/07): no purposes, no scripts — Spike reads the group's # day and decides for himself whether ONE thing is worth saying, to whom, by # name. NOTHING is a choice we respect: chosen silence sends nothing, and an # API failure sends nothing (a reminder isn't worth a canned voice). What # stays code: the nobody-on-site gate above, the budget above, the tone gate # inside lookout(). try: import wa_chat m2 = wa_chat.lookout(job, slot) if m2: process_capture.queue_question(jid, m2, long=True) log("LOOKOUT %s -> %s: %r" % (slot, job, m2[:80])) else: log("LOOKOUT %s %s — Spike chose silence" % (slot, job)) except Exception as e: log("LOOKOUT ERR %s — silent" % e) continue pool = pool_for(slot, prefix) if prefix == "drill" and slot == "knockoff" \ and any(r.get("kind") == "document" for r in rows): pour = pool_for("pour") if pour != DEFAULT_POOL["morning"]: pool = pour # docket landed today -> pour-day knock-off pool if it exists msg = pick_line(pool, slot, job, f) process_capture.queue_question(jid, msg) log("DRILL %s -> %s (%s): %r" % (slot, job, m.get("name", grp), msg)) if __name__ == "__main__": main()