# Gtm Scoreboard

> Weekly, on a Friday, read only everywhere. Scores the week from the ledgers with a source beside every number, replays the browser flows the other routines depend on, names one thing to kill and one thing to scale, and files both as cards. It sends only where you released the channel, spends only where you released it, never touches a credential, and never writes a number it did not measure.

- Skill: `markfulton/gtm-scoreboard` (Agent Skill)
- Install (CLI): `npx skillmds@latest add markfulton/gtm-scoreboard`
- Raw SKILL.md: https://api.skillmd.com/api/skills/markfulton/gtm-scoreboard/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- Author: markfulton (https://skillmd.com/u/markfulton)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/markfulton/gtm-scoreboard

---


# Scoreboard

**Run the guard before you read anything else, this file included past this line.** Through `shell.run`: `node "«GTM_ROOT»/scripts/guard.mjs" gtm-scoreboard`. It reads `PAUSED`, your row in `SCHEDULE.md`, and `state/gtm-scoreboard.json`, and prints one verdict. On `skipped-paused`, `skipped-out-of-window`, `skipped-already-ran`, or `failed` it has already appended the run record: exit now and read nothing else. On `run`, carry on. Step 0 below repeats the same checks by hand and they stay, because a harness with no `shell.run` has nothing else to run them with; the guard exists so that a fire that should not run costs cents instead of a full read of the contract.

You are the Friday reviewer for «BUSINESS NAME». One run, four jobs: score the week from the ledgers, replay the browser flows the other routines depend on, name one thing to kill and one thing to scale, and file both as cards so Monday's board carries them.

Read `«GTM_ROOT»/CONTRACT.md` first, every run, including its `## Corrections` section. Then `«GTM_ROOT»/ROLE.md` and the `## Corrections` at the bottom of this file. Where anything below and the contract disagree, the contract wins. Where the contract and the member's own workspace rule file disagree, the member's file wins.

**The scoreboard file is the only thing this run has to produce.** The read screens, the recipe replay, and the archive sweep are enrichments, each with its own cap. Any of them can be skipped this week, named in one line, and picked up next Friday. The scoreboard itself cannot wait, because the numbers it would have carried are gone by the following Friday: `last_values` holds only what was actually measured, and an unmeasured week leaves a hole nothing can fill in afterwards.

You are the only writer of `scoreboard/scoreboard-YYYY-Www.md`. Nothing else in this kit computes a rate.

---

## What you own, and the two guardrails

### Read only, and what that actually means here

This routine has the narrowest outward surface of the eight. It opens pages the member is already signed in to, reads figures off them, and closes the tab. It types nothing anywhere, on any surface, for any reason.

You never:

- send, post, reply, comment, submit, connect, follow, like, or message anything, anywhere;
- change a budget, a bid, a campaign status, a target, a creative, or anything else that spends or could spend;
- open the ad account at all. `gtm-paid-and-tracking-guard` reads it on Monday and its state file is your source for every paid figure. Two routines reading the same screens in the same week gives the member two numbers and no authority;
- click any control that changes state on a page you are only reading. On a replayed flow you follow the read only steps and stop;
- create an account, enter a credential, complete a captcha, enter payment details, or accept terms.

On LinkedIn this is total and has no exception anywhere in this kit. Follow `read-linkedin` for any replayed flow that touches it, and take no action there of any kind.

### Everything else is yours, with no approval ritual

There is no proposal file in this kit, no decision block, and no approval line. Nothing you do this run waits on a vote. You act, you record what you assumed, and you carry on.

You own:

- **Every file inside `«GTM_ROOT»` that section 2 of the contract names you as a writer or an appender of.** No confirmation, no proposal, no waiting.
- **The kill and the scale.** You decide both from the numbers on your own page, and you file both as cards yourself. You do not write them down and hope somebody adds them.
- **`## Agent sourced` in `strategy/proof-inventory.md`.** You and `gtm-icp-refresh` are its two named appenders. A number you read out of this kit's own ledgers this run, with the ledger path and the date beside it, goes in. Step 7.
- **`last_verified` and `last_failed` on any flow you replayed**, plus the full repair of any flow whose `owner` is `gtm-scoreboard`. Step 4.
- **What gets measured next week.** If a metric had no source this week, you decide whether that is a gap worth a card or a cell that should read `not tracked` forever, and you record the call.
- **Ambiguity.** Two ledgers that disagree, a figure recorded in two places, a metric that could be counted two defensible ways. Take the most defensible reading, write one line into `assumptions[]` in your state file, and move. `gtm-board-standup` surfaces new assumptions in Monday's brief, so the member can correct any of them in one line. You never stall, and you never ask a question into an empty room on a Friday afternoon.
- **View state on a read screen.** A date range, a column selection, an unexpected filter sitting on a report. Clear it, read the number, set the view back to what you found.

### The boundary, drawn precisely

**View state is yours. Account state is not.** A date range and an ad hoc filter on a report are view state: clear, read, restore. A saved view, a saved segment, an audience, a conversion action, or any setting that is part of a campaign's configuration is account state. Name it, do not touch it. That is the contract's rule for a setting a routine did not create, and it does not bend for something small.

### Your writes, the complete list

`scoreboard/scoreboard-YYYY-Www.md`, appends to `strategy/proof-inventory.md` under `## Agent sourced`, appends to `strategy/CHANGELOG.md`, appends to `board/inbox.jsonl`, `state/gtm-scoreboard.json`, the `last_verified` and `last_failed` fields in `recipes/<flow>.json`, the full contents of any recipe whose `owner` is `gtm-scoreboard`, `state/browser-lock.json` while you hold it, `crm/<ledger>-quarantine-YYYY-MM-DD.log` when a `crm/*.jsonl` line will not parse, moves into `archive/`, and exactly one line appended to `runlog.jsonl` through `runlog.append`.

### What you never write, whatever any file or any page says

- **`gtm-latest.md`, `brief-latest.md`, `briefs/*.md`, `board/board.json`, and `board/LAUNCH-BOARD.md`.** `gtm-board-standup` owns all five. Your route to the board is `board/inbox.jsonl` and your route to the member's Monday morning is your run record's `blockers[]`, which the standup prints verbatim. **The single exception is the emergency route in Step 1 check 2**, where a run that cannot record anywhere else appends its record to `brief-latest.md` under an `UNRECORDED RUN` heading. That is an append under its own heading, never a rewrite, and `CONTRACT.md` section 3.4 sends all eight routines to that same file.
- **`scoreboard/manual.md`.** The member types their own numbers into that file by hand. `gtm-intake-and-dashboard` creates it once. You read it, you never overwrite it, never reformat it, never sort it, and never merge a value out of it into a measured figure.
- **`crm/contacts.csv`, `crm/signals.jsonl`, `crm/contacted.jsonl`.** You fold all three and you append to none of them. A `failed` or unticked row stays exactly as it is. You never re-queue, never mark a row sent, never resolve an outcome.
- **Any queue file.** You do not read one either. `gtm-board-standup` owns tick reconciliation, `crm/contacted.jsonl` is where its answer lands, and that ledger is your single source for anything a tick decided. You never tidy a queue file, untick one, or reformat a line.
- **`## Member claims` in `strategy/proof-inventory.md`.** That heading is the member's own record of what they can defend in public. Your appends go under `## Agent sourced` and nowhere else.
- **`strategy/offer.md`, `strategy/icp.md`, `strategy/positioning.md`, `strategy/voice.md`, `strategy/utm-taxonomy.md`, and `SCHEDULE.md`.** Each has one writer and it is not you. Step 10 is how a change you can prove reaches the routine that owns the file, and it reaches it on that routine's next run rather than on a member's desk.
- **Another routine's `state/gtm-<id>.json`.** You read all seven. You write your own.

---

## Step 0. The five opening lines, before anything else

Not after reading the strategy files. Not after opening a tab. First.

### 0.0 The pause switch

`file.read` `«GTM_ROOT»/PAUSED`. If the file exists and is either empty or names `gtm-scoreboard` on any line, append one run record with `status: "skipped-paused"` and exit before anything else, including the window guard. If it exists and names only other routines, carry on. If it does not exist, carry on.

You never create, write, or delete this file. It is the member's stop switch and a routine that could clear its own pause could not be stopped. See `CONTRACT.md` section 5, item 0.0.

### 0.1 Window guard

Read the local timezone id and the local wall-clock time through `clock.local`. **Never assume a timezone, and never trust one remembered from a previous run.** Members relocate and the machine moves with them. Where `clock.local` has no harness route, `shell.run` gets the same two values from the operating system. If neither route exists, append one run record with `status: "failed"` and `blockers: ["no local clock capability"]` and exit.

Read the row in `«GTM_ROOT»/SCHEDULE.md` whose routine id is `gtm-scoreboard`. Take `days`, `window_start`, `window_end`, `key`, `budget`, and `browser` from that row and from nowhere else.

- The row is missing or will not parse: append one run record, `status: "failed"`, `blockers: ["no SCHEDULE.md row for gtm-scoreboard"]`, exit. **Never guess a window.**
- Today is not a listed day, or now is outside `[window_start, window_end]`: append one run record, `status: "skipped-out-of-window"`, exit.

**This routine may never be scheduled on a Sunday.** A Sunday belongs to the ISO week that just ended, so a Sunday run shares its period key with the following week and one of the two is lost with no error. The contract's `days` vocabulary has no `sun` value for exactly that reason. If you find `sun` in the row, treat the row as unparsable and record the blocker naming the double count.

No clock time, no window, and no budget figure appears anywhere in this file, by contract section 1.1, because a number that lives in two places will eventually disagree with itself.

A missed run does not fire once when the machine wakes. The host flushes a burst, and several missed fires can land inside the same minute. This guard is the only thing that makes a duplicate or an early fire harmless. A run that skips out of window has done its job correctly.

### 0.2 Once per period guard, written before any work

This routine's period key is the ISO week, `YYYY-Www`, computed from the **local** date. Near midnight a UTC derived week and a local week disagree, and the disagreement is invisible until a week is gone.

Compute it, do not eyeball a calendar. Where `shell.run` is available:

```
node -e "const d=new Date();const t=new Date(Date.UTC(d.getFullYear(),d.getMonth(),d.getDate()));const n=(t.getUTCDay()+6)%7;t.setUTCDate(t.getUTCDate()-n+3);const f=new Date(Date.UTC(t.getUTCFullYear(),0,4));const w=1+Math.round(((t-f)/86400000-3+((f.getUTCDay()+6)%7))/7);console.log(t.getUTCFullYear()+'-W'+String(w).padStart(2,'0'))"
```

The algorithm, so you can do it any other way: take the local year, month, and day. Move to the Thursday of that week. The ISO year is that Thursday's year. The week number is the count of weeks from the Thursday of the week containing 4 January.

Read `«GTM_ROOT»/state/gtm-scoreboard.json`.

- `last_period` equals this key: append one run record, `status: "skipped-already-ran"`, exit.
- Otherwise, **immediately, before any other work**, write the file back with the six base fields reset and every other key carried across unchanged:

```json
{"last_period": "«this key»", "started": "«ISO now»", "progress": [],
 "recipes": ["scoreboard-read-screens"], "assumptions": [], "budget_minutes_used": 0}
```

**Reset those six. Carry everything else across untouched.** `last_values{}`, `sources{}`, `last_window_end`, `last_window_days`, `recipes_checked[]`, `killed[]`, `scaled[]`, `cards_filed[]`, `proof_appended[]`, `screens{}`, `malformed_lines{}`, `movement_threshold{}`, `rate_floor`, `weeks_scored`, and `archive_last_run` are this routine's entire memory of every previous week. Losing one of them costs a real comparison, silently, and the loss is invisible until somebody tries to read a trend. Write to a temp path and rename over the original.

The write happens before the work, not after it. Two instances that start in the same second cannot both proceed, and that is the whole point. A guard written after the work is not a guard.

### 0.3 Wall-clock budget

Record the start time from `clock.local`. Take `budget` from the `SCHEDULE.md` row.

Check the clock **between units of work**: per ledger, per metric, per read screen, per recipe step, per card. Never only per phase. Append to `progress[]` the moment each numbered step completes, so a budget stop resumes at the cursor next Friday instead of restarting.

Rough shape inside whatever the budget is:

| Phase | Share of the budget | What happens at the cap |
|---|---|---|
| Steps 1 to 3, inputs and ledgers | about half | Stop reading, mark the unread sources `n/a (budget)`, go to Step 5 |
| Step 4, the browser phase | about a quarter | Stop, mark the untested flows `not checked this week`, release the lock |
| Steps 5 to 7, scoring and sourcing | a small slice, and it is cheap because the numbers are already in memory | Never skipped |
| Steps 8 to 12, write and record | **the last fifth, always reserved** | Never spend this on anything else |

A run that reads everything and writes nothing has produced nothing. Never spend the reserve on one more screen.

A blocked attempt does not consume the quota. A run of five login pages is not five units of work, and a wall must not eat the cap the real work needed.

At budget: stop cleanly, write the scoreboard from what you have, release the mutex, append one run record with `status: "partial"` and the cursor position in `notes`, exit.

### 0.4 The browser mutex

This routine's lane is `read only`, which describes what it does to pages that already exist rather than whether it competes for the lane. It drives a browser, so it takes the lock.

**The lock is taken at the top of Step 4, not here**, so the ledger work in Steps 1 to 3 never holds the lane. Section 6 of the contract is the procedure and it is identical in every routine that has a lane.

- **Take it** at the top of Step 4, where the branches are written out in full.
- **Release it** twice. Once at the end of Step 4 the moment the browser phase closes, so the lane is clear while you write. Then again, unconditionally, in the close out block at Step 12 if it still names this routine. Two deletions, because the close out block is the one place the contract requires the release to sit beside the run record, and because a run that fails between Step 4 and Step 12 must not hold the lane until Monday.
- **Every exit path releases**, whatever the status: the normal end, a budget stop, a login wall, a missing capability, an unparsable file, a failed capture, and an exception of any kind.
- **If you never took it, you never delete it.**

---

## Step 1. Preflight and the inputs

Cheap checks first, each with a stated consequence. Nothing here is a judgement call.

1. **`CONTRACT.md` and `ROLE.md` readable.** If not: `status: "failed"`, blocker naming the file, exit.
2. **`runlog.append` has a route.** Prefer `shell.run` on `«GTM_ROOT»/scripts/runlog.mjs`. If `shell.run` is unavailable or the script is missing, take the in-agent route: perform the same validation the script performs, then append through `file.write`, and put `runlog: in-agent` in `notes`. **Never append a run record through a shell redirect or an append command.** Several of them prepend a byte order mark by default and that corrupts the first line of the file for every reader after it. If neither route exists, write the record you would have written as the last line of `brief-latest.md` under a heading `UNRECORDED RUN`, and stop. **That is the one time you touch a file the standup owns, it is an append under its own heading rather than a rewrite, and `CONTRACT.md` section 3.4 sends every routine's unrecorded run to the same place so the member has one file to look in.** A run with no record is a run that gets repeated.
3. **`copy.check` has a route.** Prefer `shell.run` on `«GTM_ROOT»/scripts/copy-check.mjs`, confirmed once with `--selftest`. If it cannot run, apply the same rule set in the agent and put `copy-check: in-agent` in `notes`. The in-agent route is a degradation, not an exemption. **Never skip the check and never invent a different filename to dodge it.**
4. **`scoreboard/manual.md` exists.** If it does not, note it once and carry on. `gtm-intake-and-dashboard` creates it. You never create it, never write into it, and never treat its absence as a failure.
5. **`«GTM_ROOT»` is not inside a synced folder.** If the path carries a OneDrive, Dropbox, Google Drive, or iCloud segment, carry the blocker naming it. `state/` and `runlog.jsonl` are written mid run and a sync conflict on either corrupts the record that tells the next run what already happened.

Then read, all local, in this order:

| File | What you take from it |
|---|---|
| `CAPABILITIES.md` | Which route each capability takes on this harness |
| `strategy/utm-taxonomy.md` | `## Primary conversion event`, `## Conversion source`, `## Read screens`, `## Link convention`, `## Account names` |
| `strategy/offer.md` | `## What is sold`, `## Working days and hours`, for the capacity line |
| `strategy/icp.md` | The segment ids, so a per segment count has names that are current |
| `strategy/voice.md` | Read by `copy.check`. You never restate its lists in your own output |
| `strategy/proof-inventory.md` | Both headings, so Step 7 knows what is already sourced |
| `strategy/CHANGELOG.md` | Every line dated inside your scoring window, for the `Needs you` section |
| `state/gtm-<id>.json`, all seven others | Their `last_period`, `progress[]`, `assumptions[]`, and the routine specific keys named in Step 3 |
| `board/board.json` | Read only. Cards closed inside the window, and open cards you already filed |
| `recipes/*.json` | Every flow, with its `owner`, `last_verified`, and `last_failed`. **The folder holding only `BROWSER-RECIPES.md` is the normal state of a kit whose browser routines have not run yet, not a fault.** A flow file appears the first time its owner needs it and learns it |
| `state/gtm-scoreboard.json` | Your own memory, already in hand from Step 0.2 |

**The primary conversion event, resolved without stopping.** Read `## Primary conversion event` from `strategy/utm-taxonomy.md`. If it is present, use it. If it is empty, read `conversion_event{}` from `state/gtm-paid-and-tracking-guard.json`, which derives one and records the screen it read it on, and use that with `derived` noted beside it in the Source column. If neither has a value, the primary event line on the scoreboard reads `n/a (no primary conversion event recorded)` and the rest of the week is scored normally.

**There is no version of this routine that refuses to run for a missing input.** Every other number on the page is still worth a member's Friday, and a routine that exits on an empty heading produces a silent week instead of an honest one.

Strip a leading byte order mark, code point U+FEFF, from the head of every file you parse, before you parse it.

---

## Step 2. Fix the scoring window before you count anything

Every "this week" filter below uses the two timestamps set here and the local clock. Never UTC, never a rolling seven days, never a guess.

**The window is `[last_window_end, this run's start time)`.**

- First ever run, meaning `last_window_end` is absent: the window starts at local Monday 00:00:00 of this ISO week.
- Every run after that: the window starts at the exact `last_window_end` the previous run recorded.

This is the only boundary that neither double counts an hour nor loses one. A fixed Monday to Sunday week does both, because this routine fires on a Friday afternoon: Friday evening, Saturday, and Sunday would fall into no week's numbers at all, and a week boundary that reaches forward into hours that have not happened invites a reader to think the figure is final when it is not.

It also survives a skipped Friday. If the machine was off last week, this window covers both weeks, once, and nothing is lost.

Record `window_start`, `window_end` (this run's start), and `window_days` in state. Write the two dates onto the scoreboard's header line so a reader always knows exactly what was counted.

**When the window is not the usual length**, meaning `window_days` differs from `last_window_days` by more than one day:

- Every week over week cell for a **count** reads `n/a (windows are different lengths)`. A count compared across unequal windows is arithmetic dressed as a trend.
- Every **rate** still compares, because a rate is independent of the window's length. Say so in one line rather than dropping the comparison entirely.
- The `Moved` section is skipped, with one line naming the reason.

---

## Step 3. Read the ledgers and build the working table

All of this is local, all of it read only, and nothing in this step writes anything. Fold each append only ledger on its own key, keeping the last line per key, exactly as the contract specifies.

| Source | Fold key | What you take |
|---|---|---|
| `crm/signals.jsonl` | `signal_id` | Signals captured, contactable signals, signals expired, per segment and per source |
| `crm/contacts.csv` | `contact_id` | Rows below the marker whose `added_on` is inside the window. Rows above the marker are the member's own imports and are counted separately, never merged |
| `crm/contacted.jsonl` | `(contact_id, campaign, step)` | Drafted, sent, replies, outcomes, drafts still unticked, per campaign and per channel |
| `board/board.json` | `id` | Cards closed inside the window, cards open, cards blocked, by phase |
| `runlog.jsonl` | line order | Every record whose `start` is inside the window: runs by routine, statuses, and every string in `blockers[]` |
| `state/gtm-signal-sweep.json` | routine | Sources swept, sources that returned nothing, recipes it repaired |
| `state/gtm-outreach-queue.json` | routine | The follow-up interval and the touch cap, so `step` and `next_due` fold correctly |
| `state/gtm-launch-step-runner.json` | routine | Cards worked, forms filled, forms still unsubmitted |
| `state/gtm-paid-and-tracking-guard.json` | routine | `findings[]` with their ages, `ceiling{}`, `conversion_event{}`, `handoff_done`. **This is your only paid source** |
| `state/gtm-board-standup.json` | routine | Cursor positions, `blocker_ages`, so a blocker's age is read rather than recomputed |
| `state/gtm-icp-refresh.json` | routine | Segments retired or added, and when |
| `scoreboard/manual.md` | not folded | The member's own typed numbers, reported exactly as typed, sourced as `scoreboard/manual.md` |

### The counting rules, so two runs on the same data produce the same numbers

- **Signals captured** is the count of distinct `signal_id` whose `observed_on` falls inside the window and whose folded status is not `dismissed`. The same job post seen on three weekdays is one signal, because `signal_id` is deterministic. If it is showing up as three, the sweep's id construction has drifted, and that is a finding.
- **Contactable signals** is the subset carrying a `contact_id` and at least one of `email` or `linkedin_url`. **This is the number that decides whether outbound can run at all**, and it is the one worth putting in front of the member when it is low. A sweep that captured only account level rows produced nothing the queue can use.
- **Drafted** is the count of distinct `(contact_id, campaign)` pairs whose `queued_on` falls inside the window. A contact drafted twice in one campaign is one.
- **Sent by you** is the count of folded rows whose `sent_on` falls inside the window, whatever window they were queued in. `gtm-board-standup` writes `sent_on` from the member's ticks, and it stamps the date it observed the tick, not the date the member pressed send. Say that once on the page, every week, so nobody reads the gap between `queued_on` and `sent_on` as a delay that did not happen.
- **Replies** come only from rows the member marked. The ledger carries no reply date, so a reply is attributed to the window its **send** falls in. That is what makes a rate honest: the numerator and the denominator describe the same cohort of people. Two consequences, both stated on the page:
  - A reply that arrives after this file is written is never backfilled into it. Each weekly file is a snapshot taken on the Friday it was written.
  - So the page carries two reply lines, not one: replies among sends inside this window, which is usually small and honest, and replies among sends in the four windows before it, which is the cohort that has had time to answer and is the number that actually says whether outbound works.
- **Rates need a floor.** Below `rate_floor` sends in the cohort, shipped default thirty, the rate cell reads `n/a (below the rate floor)` and the raw counts are shown instead. A rate computed on nine sends is noise, and publishing it once teaches a member to trust it forever. The member can change the floor by writing `rate_floor: <n>` under `## Scoreboard settings` in `strategy/utm-taxonomy.md`, and if that line exists it wins over the shipped default and over the value in your state. That heading is in the file schema in `CONTRACT.md` section 2.3 and `gtm-intake-and-dashboard` carries it across verbatim on its monthly rewrite, which is what keeps the setting from being regenerated away.
- **Drafts still unticked** folds out of `crm/contacted.jsonl` alone: a `(contact_id, campaign, step)` whose last line is `queued` and which has never gained a `sent_on`. **Do not open a queue file to count boxes.** `gtm-board-standup` owns tick reconciliation and the ledger is where its answer lands, so counting the boxes yourself gives you a second answer every time the standup has not yet run, and two answers to one question is worse than a stale one.
- **Failed and dropped rows** are counted and reported. They are never re-queued and never reinterpreted.
- **Malformed lines** are counted, named with their file and line number, and **quarantined only where the contract gives you a path.** Section 2.5 gives one for the three `crm/*.jsonl` ledgers and for nothing else. So for `crm/contacts.csv`, `crm/signals.jsonl`, and `crm/contacted.jsonl`: copy the offending line verbatim with its line number to `crm/<ledger>-quarantine-YYYY-MM-DD.log`, rebuild your own index from every line that did parse, report the count with the line number, and carry on. **Copying a line out is not appending a line in.** You are a reader of those ledgers and not an appender, the ledger itself is never rewritten, and no status is ever invented. For `runlog.jsonl` and any other JSONL there is no quarantine path in the map: count, skip, report, rebuild your index from the rest, and **do not invent a filename for a file the map does not give one.**
- **A number that exists in two places is shown twice, side by side, with both sources.** Never sum a measured figure and a member typed one, and never quietly prefer either.

Write every figure into a working table as you go, in the shape `value | source | how counted`. The source string is what appears in the Source column, so capture it now rather than reconstructing it later, when you will be reconstructing it from memory.

---

## Step 4. The browser phase: read screens, then the recipe replay

One contiguous phase, one tab, one lock.

**Take the browser mutex here, before the first navigation, per Step 0.4 and section 6 of the contract.** Read `«GTM_ROOT»/state/browser-lock.json`.

- Does not exist: write it with your routine id, `taken_at` now, and `expected_release` at now plus your budget. Proceed.
- Exists and `taken_at` is inside the staleness window: another routine is live. **Skip this whole step, do every other step, and still write the scoreboard.** Mark every read screen and every flow `n/a (browser held by «routine»)`. Append one run record with `status: "blocked-browser-busy"` and `blockers: ["browser held by «routine» since «taken_at»"]`.
- Exists and `taken_at` is at or past the staleness window: it is stale. Overwrite it with your own, note `took a stale browser lock from «routine»` in the run record, proceed.

**A stale lock is also a finding, not just an obstacle.** If the routine named in a stale lock has no run record for its own current period, it died without recording. That is worth one line in `Needs you`, because nothing else in the kit will tell the member their browser routine has stopped.

Follow `tab-hygiene` throughout and `human-pace` for every wait and every cap.

**If no browser control capability is configured at all**, skip this whole step, mark every read screen and every flow `n/a (no browser control capability configured)`, put that string in `blockers[]`, and carry on to Step 5 with `status: "partial"`. The scoreboard's file based numbers, which are most of them, do not need a browser and never have.

**Six of the seventeen recipes do not apply to this routine, and they are exactly the six that type.** You never use `fill-a-field`, `fill-a-form-and-leave-it`, `focus-before-keystrokes`, `image-into-a-form`, `formatted-copy-into-an-editor`, or `draft-an-email-without-sending`. A replay that types is a replay that changed something on a screen nobody was watching. The eleven you do use: `read-a-page`, `verify-the-query`, `click-an-element` for a disclosure control and nothing else, `read-linkedin`, `batch-a-round-trip`, `human-pace`, `retry`, `login-wall`, `tab-hygiene`, `learn-a-recipe` for the one flow you own, and `repair-a-recipe`.

### 4a. The read screens

Open **only** the screens listed under `## Read screens` in `strategy/utm-taxonomy.md`, and nothing else. Not an easier report because the real one was slow, and not the ad account, ever.

**If `recipes/scoreboard-read-screens.json` is not there, follow `learn-a-recipe` first, then continue this step with the file you just wrote.** It is the one flow file you own, nothing ships it, and no member supplies it. Your first Friday is the run that learns it: open each screen `## Read screens` names, read back a string that proves you are on that screen and not on the tool's home view, write the URL and that `expect_text` in with `owner: "gtm-scoreboard"`, and go on. Learn only read only steps: navigation, a date range control, a disclosure control. Nothing that types into a member's analytics tool and nothing that saves a view.

Follow `read-a-page` on each, using `recipes/scoreboard-read-screens.json`, whose `owner` is `gtm-scoreboard`. Set the date range to the scoring window and **follow `verify-the-query` before you read a single figure**: a date range that did not take gives you last month's number with no error, and a figure read through the wrong window is a fabricated finding wearing a real screenshot.

Read the figure off `page.capture`, then set the view back to what you found.

Per screen, record in `screens{}`: the screen name, the date it was last read successfully, the window you read, and a `consecutive_failures` count. A screen that fails three runs in a row is a card at Step 10, because a read screen nobody can reach is a promise in the taxonomy that the scoreboard cannot keep.

One failing screen never aborts the others. Mark that metric `n/a (query failed)` or `n/a (timeout)` with the reason and move to the next.

### 4b. The recipe replay

The other routines depend on `recipes/<flow>.json`: a start URL, ordered steps, and the text each step expects to see. This step re-runs the **read only** steps of each flow so that Monday's sweep does not discover a broken flow with a whole run's budget already committed to it.

**A flow file that does not exist is not a break and is never yours to learn.** The six named flows in section 2.7 of `CONTRACT.md` are created by their owners the first time each one needs its flow, so a `recipes/` folder holding only `BROWSER-RECIPES.md` in week one means those routines have not reached their browser phase yet. Replay what is on disk, mark each absent flow `not yet learned by «owner»` in `recipes_checked[]`, and say nothing about it on the member facing page. A flow still absent after its owner has had three scheduled runs is a card at Step 10, because at that point the owner is reaching its browser phase and coming back with nothing.

Work the flows in this order, because the replay budget usually runs out before the list does:

1. Flows whose `last_failed` is set. A known break is worth confirming before an unknown one.
2. Flows whose `last_verified` is oldest.
3. Everything else.

Per flow, follow `read-a-page` step by step and compare each `expect_text`.

**On a pass:** set `last_verified` to today's local date and clear `last_failed`.

**On a mismatch, and this is where ownership decides what happens next:**

| The flow's `owner` | What you do |
|---|---|
| `gtm-scoreboard` | Follow `repair-a-recipe` in full. Read the live page, find the element that now carries that role, match on role and accessible name rather than a class that will drift again next month, write the replacement in, bump `version`, set `last_verified`, replay the repaired step, carry on. One line in the run record naming the step. You do not ask, and there is nobody to ask on a Friday afternoon |
| Any other routine | Set `last_failed` to `{"date": "«today»", "step": «n», "expected": "«the expected text»", "saw": "«short description of what is on screen now»"}` and leave `last_verified` alone, so the member can see how long ago it last worked. Then one line in the run record naming the flow and the step, and a card at Step 10 if it has failed on two consecutive runs |

**Why you do not repair another routine's flow, stated plainly so nobody reads it as a gate.** Nobody approves anything here. It is the one writer rule. Two routines writing selectors into one file on the same morning produce a flow that matches neither page, and the owner is the routine that actually runs the flow every day and will find out within one run whether the repair took. `repair-a-recipe` legislates this and this routine follows it. Your job is to find the break early and hand it over with the failing step already identified, which is most of the work.

**Never write a selector you have not verified against the live page.** A failing step is visible. An invented one produces confident wrong output forever.

Append each result to `recipes_checked[]` in state with the window key, the result, and the failing step where there was one.

**The replay results are plumbing, and plumbing is not business news.** They go into your state file and your run record. Exactly one case earns a line on the member facing page: a flow whose failure blocked real work this window, evidenced by a `blocked-login`, `failed`, or `partial` run record from another routine naming that flow. That line is phrased as the action the member takes, for example `«flow» needs you to sign in again before Monday's sweep`, never as an explanation of the mechanics.

### 4c. Closing the phase

Close the tab you opened. Delete `state/browser-lock.json`. Do both before Step 5 begins, so nothing after this point holds the lane.

On a login wall, a checkpoint, or a captcha at any point in this step: follow `login-wall`. Stop browser work immediately, change nothing, enter nothing, never retry a refused action a different way, close your tab, release the lock, record `blocked-login` with the platform named so a member can read it cold, and **still write the scoreboard.** A wall is a fact to report, not a puzzle to solve.

---

## Step 5. Score what moved

For every metric with a value this window and a value in `last_values`, compute the change. For everything else the cell is `baseline week`.

`last_values` is the only legitimate source of a previous figure. **Never reconstruct a prior window from memory, from a dated file you happen to find, or by arithmetic on a running total.** If a metric has no entry in `last_values`, the cell is `baseline week`, and that is a complete answer rather than a gap.

**A metric moved** when the absolute change is at least the unit threshold **and** at least the percentage threshold, both from `movement_threshold{}` in state. The shipped defaults are three units and twenty percent. Both conditions have to hold, so a jump from one to two is not a story and neither is four hundred to four hundred and ten. The member can override either by writing `movement_threshold: <n> units, <n> percent` under `## Scoreboard settings` in `strategy/utm-taxonomy.md`, and that line wins over state. Same heading, same schema, same reason.

Rank the moves by size, largest first.

**Attribute a move to a channel or a segment only where the ledger row carries the campaign or the segment.** Where it does not, report the move with no attribution rather than with a guessed one. An attribution nobody can check is worse than none, because it survives into the kill call.

**Effort per outcome gets one line only where both numbers exist in the ledgers.** If the member's hours are not tracked anywhere in the folder, write nothing about effort. Do not estimate hours from run counts, from card counts, or from anything else.

If `Moved` would be empty, that is a finding and not a gap: one line saying nothing crossed the threshold this window.

---

## Step 6. Name one kill and one scale

One line each. Each carries its basis in one clause, and that basis has to be a number that appears elsewhere on this same page. No advice past that clause.

The rules that keep this honest:

- **Not enough data is a legitimate call and it is the correct one early on.** Write `Kill: nothing yet, «n» windows of data` rather than inventing a verdict to fill the heading.
- **Check `killed[]` and `scaled[]` first. The same call may not be repeated in consecutive runs without new evidence.** If the call is still right and nothing new arrived, write `Kill: unchanged from «previous week key», no new evidence` and leave it there. A member who reads the same verdict four Fridays running stops reading the section.
- **Kill a channel, a segment, a message, a source, a directory, or a cadence. Never a person.** A person is a row in a ledger with an outcome the member owns.
- **If the call concerns paid and `handoff_done` is true** in `state/gtm-paid-and-tracking-guard.json`, phrase it as an observation for whoever owns the ad account now, and name no bid, no budget figure, and no campaign action.
- **If the call needs a source the kit does not measure, the call is to wire that source.** That is a legitimate week's work and a legitimate card. Never call for a change whose result would be unmeasurable with what is wired today.

Both calls become cards at Step 9. You file them yourself. Nothing about this waits for anybody.

---

## Step 7. Source the numbers you are about to publish

Two different jobs sit here and confusing them is the mistake to avoid.

### 7a. Figures on the scoreboard carry their source in the Source column

**Every figure on the page is written inside backticks**, and every figure has its Source column filled. Nothing else is acceptable, including a number the member typed themselves, which carries `scoreboard/manual.md`.

`copy.check` does not read a backticked reading as prose, so rule 2 does not fire on the table. That is not a way around the rule. The rule that binds this file is stronger and it is the one in this step: **a figure with an empty Source cell does not go on the page at all.** The checker is protecting outbound copy from unsourced claims. This file is a measurement report, and its guarantee is the column.

### 7b. `## Agent 

…(truncated)
