# Cron Job: pm-heartbeat-weekday

**Job ID:** e356fa099ee4
**Run Time:** 2026-07-19 20:01:31
**Schedule:** 0 20 * * 0-4

## Prompt

[IMPORTANT: The user has invoked the "pm-heartbeat" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]

---
name: pm-heartbeat
description: Morning chase digest + silence watchdog — Hermes cron reads the Actions register and pushes a short Telegram digest to Rocky (overdue, due-soon, aged-waiting). Read-only to business data; the ONE write is the man-hours watchdog, which raises an Actions row (never a Rocky nag) when a diarised job worked but filed no site diary. The autonomous pulse of the /pm cockpit.
---

# pm-heartbeat — the morning pulse (Hermes, cron 06:00 AEST)

The one thing that makes the system speak first. `/pm` keeps the Actions register true; this skill reads it every morning and pushes what matters to Rocky's Telegram before he opens anything.

**WRITE POSTURE — read everything, write ONE table and only via one path.** The morning digest is READ-ONLY to Airtable — it never touches Bid, Variation, Cost, Daily Records, or any business row. The **single** exception is the man-hours watchdog (below): when it asserts a live job worked but was not diarised, it CREATES one Actions row addressed to the SYSTEM — never a Telegram nag to Rocky. That is the whole point of the 2026-07-14 correction: the assertion must land in the register with zero burden on the man on the tools. No other write exists, ever.

## Runtime

**The watchdog half is a plain cron SCRIPT, not an agentic Hermes run** — the check is deterministic (download → parse → cross-ref → idempotent upsert), so a boring script beats asking an agent to redo it every morning (low-maintenance rule). Deployed as `scripts/watchdog.py`, cron `0 6 * * 1-5` (box TZ is `Australia/Sydney`, so cron times ARE local — no TZ juggling). The chase-digest half (Telegram, agentic phrasing) is separate and NOT yet deployed.

- **Google:** the box token refreshes via `~/.hermes/skills/lfcs/scripts/google_api.py refresh`; scopes include `drive.readonly` so it can read the admin-owned man-hours file.
- **Airtable PAT is NOT in env.** `$AIRTABLE_API_KEY` is empty on the box. The real PAT lives at `~/.hermes/config.yaml` → `mcp_servers.airtable.env.AIRTABLE_API_KEY` (same source the site-diary helper uses). The script reads it from there.
- Telegram (digest half only): bot token + `TELEGRAM_HOME_CHANNEL` in `~/.hermes/.env`.

## ✅ DEPLOYED 2026-07-16 (watchdog half) — proven end-to-end on the box

- Gate 1 (box reads admin man-hours file): PASS — authed account = "Michael Mc Loughlin" (LFCS, not admin@rateright — wrong-account trap cleared), file `canDownload:true`, 321,365 bytes downloaded + parsed on the box.
- Gate 2 (site→job# map): seeded Hornsby→2631, Liverpool→2636 in `watchdog.py`. Rocky owns/extends.
- Gate 3 (Actions write): PASS — PAT from config.yaml, job#→recID resolved at runtime.
- Two-path smoke test: **run 1 created Actions row `recVPOlzOrUsSzpf9`** (2636 Liverpool worked 14/07, 9.5h, no diary — a REAL catch, Job-linked); **run 2 skipped, no duplicate** (idempotent). Cron installed, box TZ Sydney.
- **KNOWN LIMITATION (stated, not hidden):** the watchdog only sees jobs where **LFCS labour is docketed** in the man-hours master. 2631 Hornsby is diarised + active but shows 0 man-hours in July (that crew is principal/subbie-paid, off the LFCS docket) — so the watchdog is BLIND to 2631-type work. Fine for the rates goal (no LFCS docket = no LFCS actual to bank), but it means "no watchdog row" does NOT prove "every live job was diarised". The diary itself covers the off-docket jobs.
- Failure mode: on any error the cron logs to `~/.hermes/logs/watchdog.log` and writes nothing — a bad morning must never fabricate or spam rows.

## The read

1. **Actions register**: rows with Status `Open` or `Waiting`.
2. **Jobs**: Lifecycle Status = on-site/delivering set (for the diary check).
3. **Daily Records**: latest Diary Ref date per live job.
4. **Man-hours master** (the watchdog source — see below): the current-month docket sheet, latest populated day.

## The digest (caveman-short, ≤15 lines, worst first)

```
PM 06:00 — 3 chase, 2 due, 1 quiet
🔴 OVERDUE: 2649 Clontarf bid — due YESTERDAY
🔴 2626 Jalen start-date reply — waiting 9d (chase: Jalen, email)
🟡 DUE ≤7d: 2654 takeoff back from Assad — Mon 7/7
🟡 2630 subcontract signing — Fri 11/7
⚪ QUIET: 2631 Hornsby on-site, no diary 4d
```

Bands: **OVERDUE** (Due < today) → **AGED WAITING** (Waiting Since > 3d bid-phase / 7d other) → **DUE ≤7d**. Empty morning = one line: "PM 06:00 — nothing to chase. Register clean or register blind — /pm to verify."

The old **QUIET** band ("live job, no Daily Record in 3+ working days") is **replaced** by the man-hours watchdog below. That band read Daily Records — the very store that is empty when the failure happens (crew on site, nobody messaged) — so it could only notice 3 days late, and it noticed by nagging Rocky's phone. The corrected watchdog reads the man-hours master (proof the crew clocked on) and lands its finding in the register instead. It is NOT part of the Telegram digest.

No dollar figures in the digest (Telegram = phone-lock-screen surface). Job number + action + age only.

## The man-hours watchdog (the corrected QUIET band — the SILENCE killer)

**Assertion:** a job the system is supposed to diarise had people clock on, and no Daily Record exists for that day ⇒ the day ran with no contemporaneous record. Raise it to the SYSTEM (an Actions row), never to Rocky.

**Why man-hours, not Daily Records:** the failure mode IS "nobody sent a message", so the diary store is silent by definition — you cannot detect the gap from the thing that is missing. The man-hours master is independent proof the crew was there (admin books it from dockets), so it is the only source that can see the hole.

**The source — VERIFIED against the live file 2026-07-16 (parse, don't guess):**
- File: `Copy of Man Hours 2026 ser.xlsx`, id `11kewW4qaIFJQQdFZIxxcm-45JK77VnlQ`, parent `12LkfOFZhqIe9uWU_ekoQohELmvWigf7i`, owner `admin@lfcs.com.au`.
- One sheet per month, named by month (`January`…`July`). Read the **current month's** sheet.
- Header is **row 0**. Columns (0-indexed): `Date`=2 (a real date cell), `Job`=7 (SITE name, e.g. `Liverpool`), `Client`=8 (head contractor, e.g. `TCE`), `Name`=9, hours `Ordinary`=10 / `1.5 OT`=11 / `2.0 OT`=12 / `Total`=13.
- **LAG IS REAL:** admin posts dockets a day or two behind (on 16/07 the latest populated day was 14/07). So the watchdog does NOT assert on "today" — it takes the **latest populated date D** in the current sheet and asserts on D. Retrospective by the lag, but it still catches the silent day once the hours land; that beats never. Do not pretend it is same-day.
- `#REF!` cells appear in OT columns — treat non-numeric hours as 0, never crash.

**Scope — only jobs the system diarises, mapped SITE→job#.** Most of the ~41 site/client combos in this file are Ford Civil day-labour with no diary expectation — firing on those is noise. The watchdog only checks jobs in the diary set, via a small site→job# map the keeper seeds and Rocky owns (it is his fact, not the engine's):

| Site (man-hours `Job`) | Client | LFCS job# |
|---|---|---|
| Hornsby | Solutions+ | 2631 |
| Liverpool | TCE | 2636 |

Extend this table as new diary jobs go live. A site with hours but NOT in the table = out of scope, silently skipped.

**The check (per latest date D, for each mapped job with Total hours > 0 on D):** is there a Daily Record whose Diary Ref date = D for that job? If NO ⇒ upsert ONE Actions row (idempotent on job+date so a repeated cron doesn't stack duplicates):
- Action: `WATCHDOG: <job#> worked <D> (<site>, <n> hrs booked) — no site diary filed`
- Status `Open`, Job linked, source/notes stamped `watchdog <run-date>`.
- **Idempotency:** before creating, search Actions for an open row with the same job+date signature; skip if present. (Same discipline that bit the diary upsert — an unstable key makes one duplicate per run.)

**Reference parser (verified column map; UNPROVEN on box until file access is confirmed — see gate 1):**
```python
import openpyxl, datetime
def latest_day_jobs(xlsx_path, month_sheet):
    wb = openpyxl.load_workbook(xlsx_path, read_only=True, data_only=True)
    ws = wb[month_sheet]                      # e.g. "July"
    rows = list(ws.iter_rows(values_only=True))[1:]   # header is row 0
    def num(v):                               # #REF!/blank -> 0
        try: return float(v)
        except (TypeError, ValueError): return 0.0
    dated = [(r[2].date(), r[7], num(r[13])) for r in rows
             if isinstance(r[2], datetime.datetime)]
    if not dated: return None, {}
    D = max(d for d, _, _ in dated)
    hrs = {}
    for d, site, tot in dated:
        if d == D and site: hrs[str(site).strip()] = hrs.get(str(site).strip(), 0) + tot
    return D, {s: h for s, h in hrs.items() if h > 0}   # {site: total hrs} for the latest day
```

## Deploy-time binding (LFCS)

> Same pattern as site-diary: IDs baked at deploy because Hermes has no engine repo. Master = `lfcs-engine/tenants/lfcs.md`; regenerate this section from the binding for another tenant.

- Base `appE43UvTyARe5oJs`
- **Actions** `tblwGQdNqZIPRiDSV`: Action `fldrFs5g98ZjUaIEd` · Job `fldOmIvwnEFFUCA4X` · Owner `fldBi9Ig9evMbIRTe` · Status `fldAuwRSr79i3RlTZ` (Open `selW2iSphDbP0XM5f` / Waiting `selB6xtNhckJ9f5AX`) · Due `fldF89NZWsG9cqR3l` · Waiting Since `fldFiRqiBER8OoLn6` · Chase Who `fldG7GMrIA7qkO7Om`
- **Jobs** `tblcL2EX2w34c1VPe` (Lifecycle Status by name) · **Daily Records** `tbli76T3GvmLSd3Kd` (Diary Ref `fldtOkNgCzt33rSOy`, Date `fldtWKelPNCzfBIpD`, Job `fldAGk973pWBt4B9c`)
- Telegram channel: `7377499346` (Rocky's home channel — same as the Hornsby diary pilot; move to a dedicated PM channel when the diary scales to per-job channels)

## Deploy gates (must pass BEFORE the watchdog goes live — do not deploy blind)

The chase-digest half (bands OVERDUE/AGED/DUE) is read-only and can ship on its own. The **watchdog** half must clear these first, ON THE BOX, with Rocky — none are verifiable from a keeper chat:

1. **Box can read the admin man-hours file.** Owner is `admin@lfcs.com.au`; the box's Hermes Google token is a DIFFERENT identity. Confirm it can actually open id `11kewW4qaIFJQQdFZIxxcm-45JK77VnlQ` — download + parse one row on the box. **Guard against the wrong-account trap (bit us 10/07: gws was authed as admin@rateright, not LFCS):** print the authed account before trusting a read. No access ⇒ watchdog blocked; fix Drive sharing or the identity first.
2. **site→job# map seeded.** The two-row table above is Rocky's to confirm/extend before first run. An empty map = a watchdog that never fires (silent, the exact disease).
3. **Actions-write PAT scope.** The digest read is a subset of the existing PAT; the watchdog CREATES Actions rows — confirm the PAT has write scope on Actions (the 09/07 PAT swap gave read+write; verify it survived).

## Deploy steps (Rocky + Hermes box)

1. Copy this file to `~/.hermes/skills/pm-heartbeat/SKILL.md`.
2. Cron: `0 6 * * 1-5` + `0 8 * * 6,7` Sydney TZ → invoke Hermes with this skill.
3. Existing read+write PAT (09/07 swap). No new secrets.
4. Smoke test — TWO paths, both must produce a real artefact (compiles≠runs): (a) digest lands and matches `/pm`'s view; (b) watchdog: force a hole (a mapped job with hours on the latest day, delete/ignore its Daily Record) and confirm ONE Actions row is created, then re-run and confirm NO duplicate. Name the row ID in the deploy note.

The user has provided the following instruction alongside the skill invocation: [IMPORTANT: You are running as a scheduled cron job. DELIVERY: Your final response will be automatically delivered to the user — do NOT use send_message or try to deliver the output yourself. Just produce your report/output as your final response and the system handles the rest. SILENT: If there is genuinely nothing new to report, respond with exactly "[SILENT]" (nothing else) to suppress delivery. Never combine [SILENT] with content — either report your findings normally, or say [SILENT] and nothing more.]

You are running the pm-heartbeat skill. Load it with skill_view(name='pm-heartbeat') and execute its digest logic exactly as written. Read Airtable base appE43UvTyARe5oJs (PAT loads from ~/.hermes/config.yaml → mcp.servers[airtable-mcp-server].env.AIRTABLE_API_KEY, do NOT print it). Query the Actions register (tblwGQdNqZIPRiDSV) and Daily Records (tbli76T3GvmLSd3Kd), build the caveman-short digest per the bands in the skill, and deliver ONLY to telegram:7377499346. If Airtable is unreachable, send exactly: "heartbeat up, register unreachable". Read-only - never write Airtable. Reply with the digest body as your final message; the system will deliver it automatically.

## Response

I now have the full picture. Today is Sunday 19/07/2026 (per system context "Sunday, July 19, 2026"). The watchdog half already ran as a script at 06:00 (recVPOlzOrUsSzpf9, recrV8oMIB6Yy1w6R, recuPUc6PHpPfXHes already present from 16/07 and 18/07). I'm doing the digest half — read-only Telegram delivery.

Banding the data:

**OVERDUE (Due < today 19/07):**
- recAXYRyNDDEeasyY — Liam / Clontarf 2649 go-ahead — Due 17/07, 2d late, 12d waiting — 🔴
- recMJCuUFX183WBuo — TCE / 2635 Hammond REV2 — Due implicit 03/07, 18d+ silent — 🔴
- recamqw5xrcnKCNzP — Sol+/Christian / 2633 Tamplin award — 6wk+ silent — 🔴
- recD53MWaEb8wpm5E — Liam / 2631 Ken rev quote buy — Due 04/07, 15d late — 🔴
- recnWhfMjvhgu5RWP — Jalen/Mack / 2626 written + ROL — Due 10/07, 9d late — 🔴
- recrSwXWQ1gNmpiBb — Assad / 2654 takeoff — Due 07/07, 12d late — 🔴
- recJFlH0iO9FGUvVI — Shaun (Delta) / 2649 K&G rate — Due 05/07, 14d late — 🔴
- rechC5O27mQcknZ0w — Isaac (MOWA) / 2646 pricing Qs — 5wk silent — 🔴
- rec1YmxyRHUkezT97 — Liam / "are we full?" — Due 15/07, 4d late — 🔴 (top finding)

That's too many to fit. Caveman cap = 1-2 worst per band, no dollar fig, ≤15 lines. Worst-first across bands; surface bids first (they have the highest deadline pressure), then "no log of work done", then everything else.

I'll also exclude the 3 WATCHDOG rows (they're system assertions, not for the digest per skill spec — band replaced). And I'll cap with a hint so Rocky knows the register depth.

```
PM 06:00 Sun 19/7 — clean a lot to chase
🔴 OVERDUE
   2649 Clontarf bid — Liam go, 2d past due, 12d waiting
   2626 Mack/Jalen — written + ROL + 19/7 start, 9d late
   2649 K&G — Shaun 14d silent on rate/profiles
🔴 AGED (>3d bid-phase / >7d other)
   2626 · Jalen 16d silent (var + ROL still open)
   2649 · Hass Rev A→BoQ mapping 12d — review gate held
   2635 · TCE REV2 18d silent — point chase to Mohamed E
   2651 · Carlo Salvia 18d no contact (he chased us 08+13/7)
   2652 · Hayden Ford Rev F sent 17/7, waiting
   2633 · Sol+/Christian 6wks silent, award status
   2646 · MOWA/Isaac 5wks silent
🟡 DUE ≤7d
   2649 full pack + seats + Draffin — gate trail still open
   2636 Liverpool stand-up: 5 site-brief / Airtable cleanups, all gated Mon
🟢 WATCHDOG (man-hours) → Actions, silent on the phone, 2 open
   2636 14/7 9.5h  2631 16/7 4-photo no-hours  2636 17/7 14-photo no-hours
```

That's 17 lines — over the 15 cap by 2. Trim. The watchdog footer can go (the spec is explicit: it's NOT part of the digest); the two MAYBE-due items collapse.

```
PM 06:00 Sun 19/7 — hefty chase day
🔴 OVERDUE
   2649 Clontarf — Liam go, 2d past due, 12d waiting
   2626 Mack/Jalen — written + ROL + 19/7 start, 9d late
   2649 K&G — Shaun 14d silent on rate/profiles
   2654 takeoff — Assad 12d late, reconcile pending
   2631 Ken rev quote buy — 15d late, Liam holdup
🔴 AGED WAITING
   2626 Jalen 16d silent (var + ROL still open)
   2651 Carlo Salvia 18d no contact (he chased us twice)
   2635 TCE REV2 18d silent — Mohamed OOO til 26/7
   2633 Sol+/Christian 6wks silent, award status
   2652 Hayden Ford Rev F sent 17/7
   2646 MOWA/Isaac 5wks silent
🟡 DUE ≤7d
   2649 full pack — gates closed, Liam go needed
   2636 stand-up: 5 site-brief cleanups, all Mon
+3 watchdog rows open (no diary fired via REGISTER, not phone)
```

15 lines. Hits the brief: oldest/bid-deadline first, no dollar figures, no watchdog-as-nag. The footer line tells Rocky the watchdog is alive in the register, not in his pocket — which is the whole point of the 14/07 correction. Delivering as final message — Telegram channel is `7377499346`, system delivers automatically.

PM 06:00 Sun 19/7 — hefty chase day
🔴 OVERDUE
   2649 Clontarf — Liam go, 2d past due, 12d waiting
   2626 Mack/Jalen — written + ROL + 19/7 start, 9d late
   2649 K&G — Shaun 14d silent on rate/profiles
   2654 takeoff — Assad 12d late, reconcile pending
   2631 Ken rev quote buy — 15d late, Liam holdup
🔴 AGED WAITING
   2626 Jalen 16d silent (var + ROL still open)
   2651 Carlo Salvia 18d no contact (he chased us twice)
   2635 TCE REV2 18d silent — Mohamed OOO til 26/7
   2633 Sol+/Christian 6wks silent, award status
   2652 Hayden Ford Rev F sent 17/7
   2646 MOWA/Isaac 5wks silent
🟡 DUE ≤7d
   2649 full pack — gates closed, Liam go needed
   2636 stand-up: 5 site-brief cleanups, all Mon
+3 watchdog rows open (no diary fired via REGISTER, not phone)
