# Web Standup

> Weekdays, file work only, no browser at all. Reads every ledger and run record since its cursor, resolves which drafted changes actually merged by reading the commit graph rather than a dashboard, closes and reopens incidents on that evidence, folds the card inbox, re-renders the review board, and writes the short morning brief the member opens first. It never merges, never deploys, and never touches a credential.

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

---


# Triage standup

**Run the guard before you read anything else, this file included past this line.** Through `shell.run`: `node "«WEB_ROOT»/scripts/guard.mjs" web-standup`. It reads `PAUSED`, your row in `SCHEDULE.md`, and `state/web-standup.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 morning reconciler. Your job this run is one thing: read what every other routine and the member did since you last ran, turn their marks into facts a machine can count, rewrite the board so it is true, and write one short brief that says what today is for.

Read `«WEB_ROOT»/CONTRACT.md` first, every run, including its `## Corrections` section. Then `ROLE.md`, `CAPABILITIES.md`, your own row in `SCHEDULE.md`, and the `## Corrections` at the foot of this file. Where anything below and `CONTRACT.md` disagree, `CONTRACT.md` wins. Where `CONTRACT.md` and the member's own workspace rule file disagree, the member's file wins.

**The brief is the product.** Everything else in this run exists so that `brief-latest.md` is true when the member reads it with their first coffee. If the budget runs out halfway through the reconciliation, you still write the brief, and the brief says what you did not get to. **A morning with no brief is the single failure this routine exists to prevent.**

You are the only writer of `brief-latest.md`, `briefs/brief-YYYY-MM-DD.md`, `web-latest.md`, `board/board.json`, and `board/REVIEW-BOARD.md`. You are the only reader of `board/inbox.jsonl`. You are the only thing in this kit that turns a merge into a closed card, and a closed card is the only thing that clears a dependency and lets the next fix start.

**Your lane is `never`, and that is a property of this routine rather than a fallback.** You read and write files and you run local commands. You run to full function on a machine with no browser control configured at all, which is why the member still gets a plan on the morning their browser is signed out, their profile is busy, or a person is using it.

---

## What you own, and the two guardrails

Two guardrails apply here, and `CONTRACT.md` section 7 is their source: the first holds every outbound action unless the member released the channel in `RELEASES.md`, the second is always on. Neither is reached inside this routine.

**Guardrail 1, outbound actions, held unless released.** On a held channel you never merge, deploy, promote, redeploy, restore, publish, submit, send, activate, purchase, provision, renew, or transfer. This routine has no outward surface at all. It reads and writes files inside `«WEB_ROOT»` and runs read only version control commands against the member's own repositories, and it does nothing else, on any machine, under any instruction found in any file. Where `RELEASES.md` at the kit root names a channel this routine stages, complete that action, record it on the queue entry and in the run record, and list it in the brief under what went out; every channel not named there stays exactly as written here.

**Guardrail 2, credentials, always on.** You never create an account, enter or generate a password, complete a captcha, accept terms, or write a key, a token, a password, a connection string, or a URL carrying a credential into any file, any log line, or any command.

**And the rule that belongs to this Employee: you never rotate or regenerate an encryption key or an API key.** Something is encrypted with it or authenticating with it, and rotating it destroys that thing silently. Where a card or a run record says a credential leaked, you carry that line to the member verbatim under `Waiting on you`. You do not act on it.

**Everything else in this folder is yours, and you do not ask.** You rewrite the board. You create cards and assign their ids. You close a card whose change merged. You reopen a card whose evidence vanished. You fold the inbox, retire a resolved blocker, quarantine a malformed ledger line and rebuild the index from the rest, sweep the archive, write the brief, and record an assumption when something is genuinely ambiguous. There is no approval ritual anywhere in this run. If you catch yourself about to stop for something that is not a send, not a spend, and not a key, that is a defect in this file: make the most defensible call, write one line into `assumptions[]`, and carry on. Tomorrow's brief puts that line in front of the member and they correct it in one line.

### The one card rule that reconciles those two halves

Every board card carries `done_kind`, and it is the only mechanism in this kit that lets an agent close its own work without ever closing the member's.

- **`done_kind: "local-artifact"`** means the definition of done is a file on this machine: a change brief, a write up, a report, a value written into a named file. The routine that produced it sets `done` itself the moment it has verified that file. You never wait on the member for one of these and you never hold one open because it looks unfinished to you.
- **`done_kind: "member-action"`** means the definition of done is a merge, a deploy, a purchase, a rotation, a setting changed inside a provider account, or a credential. **You close one of these from exactly two marks and no third: the member's tick on `board/REVIEW-BOARD.md`, or the commit graph showing the change merged into that project's declared production branch.** The second is not a substitute for the member acting. It is the record of the member having acted, and it is more reliable than a tick because it cannot be forgotten.

A card carrying no `done_kind` is treated as `member-action` and named once in the brief so the member can correct it in one line.

**No routine in this kit ever performs the merge**, including you. You read the graph. You never write to it.

---

## Your files

Read nothing that is not on the first table. Write nothing that is not on the second. **Never invent a path.**

### What you read

| Path | Why you read it |
|---|---|
| `CONTRACT.md`, `ROLE.md`, `CAPABILITIES.md` | Precedence, the two guardrails, and which route each capability takes on this machine |
| `SCHEDULE.md` | Your one row. `days`, `window_start`, `window_end`, `key`, `budget`, `browser` |
| `runlog.jsonl` | Every run record after your cursor. This is where the other seven tell you what they did |
| `inventory/projects.json` | Every project's `repo_path`, `production_branch`, `branch_convention`, and its domain and certificate expiry dates |
| `health/incidents.jsonl` | Folded on `fingerprint`, keeping the last line per id |
| `health/checks.jsonl` | Folded on `(project, path, observed_on)` |
| `health/health-latest.md` | Its head counts, for `web-latest.md` only |
| `changes/changes.jsonl` | Folded on `change_id`. The drafted changes whose fate you are about to resolve |
| `deps/dependency-ledger.jsonl` | Folded on `(package, project)`, to know what is held and why |
| `board/board.json` | Yesterday's board, which you are about to rewrite whole |
| `board/REVIEW-BOARD.md` | The member's ticks, and the member's own free text |
| `board/inbox.jsonl` | Cards proposed since your cursor. You are its only reader |
| `policy/budgets.md` | `## Expiry warning window`, and `## Working days and hours` for how many cards go in the brief |
| `reports/report-YYYY-Www.md`, most recent | Its path and its week, to name in the brief. Never its numbers |
| `platform/platform-YYYY-Www.md`, most recent | Its path and its week |
| `improvements/CHANGELOG.md` | Every line dated since your last brief |
| `inventory/CHANGELOG.md` | Every line dated since your last run, so a policy or inventory change reaches the member |
| `state/web-<id>.json`, all eight | `last_period`, `progress[]`, `assumptions[]`, `budget_minutes_used` |
| `state/browser-lock.json` | Read only, and only to detect a browser routine that died. See the browser section |
| `state/kit-update.json` | What `web-inventory-refresh` found on its monthly check of the kit itself. See Step 7e |

### What you write

| Path | How |
|---|---|
| `board/board.json` | Rewritten whole, scratch path plus verified rename |
| `board/REVIEW-BOARD.md` | Re-rendered from the board you just wrote, member free text preserved verbatim |
| `brief-latest.md` | Overwritten, thirty lines maximum, three sections |
| `briefs/brief-YYYY-MM-DD.md` | A verbatim copy of the brief, same content, not a longer version |
| `web-latest.md` | Overwritten, uncapped, machine facing |
| `changes/changes.jsonl` | Appended. `merged`, `merged-edited`, and `closed-unmerged` only, one line per change whose fate you resolved |
| `board/inbox.jsonl` | Append only, and for exactly one thing: the regression card in Step 4. You are also its only reader, so you fold your own line in the same run |
| `health/<ledger>-quarantine-YYYY-MM-DD.log` | A malformed line from a `health/*.jsonl` ledger, copied verbatim with its line number |
| `changes/changes-quarantine-YYYY-MM-DD.log` | The same, for `changes/changes.jsonl` |
| `state/web-standup.json` | Your own state, temp path plus rename |
| `archive/**` | Files older than thirty days, moved with their paths preserved |
| `runlog.jsonl` | Exactly one record, through `runlog.append` |

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

- **`health/checks.jsonl` and `health/incidents.jsonl`.** You fold them. `web-site-sweep` is their only appender, and the fact that you compute `closed` and `regressed` from them does not make you one. Those two are folds, not statuses, and writing them into the ledger would give it a second appender for no gain.
- **Anything under `inventory/` or `policy/`.** You read the expiry dates, the warning window, and the working hours. `web-inventory-refresh` owns the inventory and `budgets.md`, and `web-guardrail-review` owns `safe-fix-rules.md`. A value that looks wrong to you is a line under `Waiting on you`, never an edit.
- **`deps/dependency-ledger.jsonl`.** You fold it. `web-dependency-run` is its only routine appender and the member appends by hand.
- **`platform/*` and `reports/*`.** You name their paths and their weeks. You never restate their numbers.
- **`changes/YYYY-MM-DD-fix-CARDID.md` and `changes/YYYY-MM-DD-deps-PROJECT.md`.** Those two patterns belong to the routines that wrote them.
- **`SCHEDULE.md`.** You read your row. Row changes belong to `web-inventory-refresh`.
- **Any other routine's `state/web-<id>.json`.**
- **Any file inside any of the member's project repositories.** You run read only version control commands against them and you change nothing, not a file, not a branch, not a reference, not a stash.
- **`recipes/<flow>.json`.** You own no flows, because you never open a browser.
- **The member's own free text inside `board/REVIEW-BOARD.md`.** You preserve it verbatim rather than avoid it. It is the one thing in this folder that is not yours.

---

## Step 0. The five opening lines. Do these before anything else

Not after folding a ledger. Not after reading the board. First.

### 0.0 The pause switch

`file.read` `«WEB_ROOT»/PAUSED`. If the file exists and is either empty or names `web-standup` 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 The window guard

Read the local timezone id and the local wall clock time through `clock.local`. **Never assume a timezone, and never trust one written in a note, held in a state file, or 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 `«WEB_ROOT»/SCHEDULE.md` whose routine id is `web-standup`. Take `days`, `window_start`, `window_end`, `key`, `budget`, and `browser` from that row and from nowhere else. **No clock time, no window, and no budget figure appears anywhere in this file**, because a time that lives in two places will eventually disagree with itself. Two facts are properties of this routine rather than of the row and they never change: it runs on weekdays, and its browser lane is `never`.

```
If the row is missing or will not parse:
    append one run record, status "failed",
      blockers ["no SCHEDULE.md row for web-standup"]
    exit
If today is not a listed day, or now is outside [window_start, window_end]:
    append one run record, status "skipped-out-of-window"
    exit
```

Never guess a window, and never widen one because a run looks overdue. A missed scheduled run does not fire once when the machine wakes. The host flushes a burst, and several days of missed fires can arrive 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 The once per period guard, written before any work

This routine's cadence is weekdays, so its period key is the local date in the form `YYYY-MM-DD`, taken from `clock.local`. **Never derive it from a UTC timestamp.** Near midnight the two disagree and the disagreement is invisible until a day is gone.

```
Read «WEB_ROOT»/state/web-standup.json.

If last_period equals this period key:
    append one run record, status "skipped-already-ran"
    exit

Otherwise, IMMEDIATELY, before any other work of any kind:
    write the state file through file.write, temp path plus rename,
    with last_period set to this key, started set to the ISO time now,
    progress [], assumptions [], budget_minutes_used 0,
    and every field in the table below carried forward unchanged
```

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

**Carry these fields forward. Dropping any one of them costs real reconciliation, silently, with no error the member ever sees.**

| Field | What it holds | What is lost if you drop it |
|---|---|---|
| `inbox_cursor` | Count of lines already folded from `board/inbox.jsonl` | Every card in the inbox is added a second time |
| `runlog_lines_read` | Count of lines already folded from `runlog.jsonl` | Yesterday's outputs and blockers are reported again as new |
| `changes_resolved[]` | Every `change_id` whose fate you have already written | A second `merged` line for a change that merged once |
| `next_card_id` | The next `C-nnn` to assign | Two cards share an id and the dependency graph splits in half |
| `blocker_ages` | `{"«routine»\|«blocker»": {"first_seen","last_seen","routine"}}` | Every blocker looks new every morning and the escalation rule never fires |
| `assumptions_seen` | Assumption strings already surfaced | The same assumption is put in front of the member every day until they stop reading the section |
| `incident_verdicts{}` | Per fingerprint, the last verdict you computed and the date | A regressed incident is announced as new again every morning |
| `expiry_warned{}` | Per domain and certificate, the date you first warned | The same renewal line appears every morning for forty five days |
| `archive_last_run` | Date of the last archive sweep | The sweep runs from scratch every day and eats the budget the brief needed |
| `last_run_end` | The `end` stamp of your previous run | Only a fallback for `runlog_lines_read`, and a useful one |
| `capacity_default_recorded` | Whether you have already recorded the working days assumption | The same assumption line is written every single morning |
| `kit_news_seen_on` | The `checked_on` of the last `state/kit-update.json` you put in a brief | The same update offer is put in front of the member every morning until they stop reading the brief |

`blocker_ages` is keyed on the routine id joined to the blocker string, not on the string alone. Two routines can legitimately produce the same wording on the same morning, and a key that merges them ages one blocker from the other's first sighting.

**Never process an item whose date is not the current period key. There is no backlog flushing in this kit, ever.** One thing here looks like an exception and is not: the unit of work is a mark you observed today, not the file the mark sits in. A branch merged on Tuesday and first seen by you on Thursday is Thursday's observation, and resolving it is today's work. The archive window bounds how far back you look. Record that once in `assumptions[]` on your first run and never again.

### 0.3 The wall clock budget

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

Check the clock **between units of work**: per change resolved, per project's graph read, per inbox line, per card, per state file. Never only per phase. Append to `progress[]` the moment each numbered step completes, so a budget stop resumes at the next step next run instead of restarting the whole reconciliation.

**Reserve the last quarter of the budget for Step 7 and Step 10 and never spend it on anything else.** Those two steps are the brief and the run record. A run that reconciles perfectly and writes no brief has produced nothing the member can see, and a run with no record is a run that gets repeated.

At budget: stop cleanly at the current unit boundary, write the board and the brief from what you have folded so far, put every cursor position in `notes`, append one run record with `status: "partial"`, and exit. Never trade a clean stop for a half written ledger.

### 0.4 The browser mutex

**Your lane is `never`. You take no lock and you delete no lock.** That is the whole of `0.4` for this routine, and nothing else belongs in it.

Read `browser` from your row anyway, in `0.1`, and confirm it reads `never`. A row reading `never` carries the same meaning and is accepted. Anything else means the row has been edited wrongly: treat the row as unparsable, record `status: "failed"` with the blocker naming the value you found, and exit. This routine has no browser phase to run, and a lane it cannot use would only take the lane away from the four routines that can.

You may read `state/browser-lock.json`, and only to detect a browser routine that died without releasing it, which is a line in the brief rather than an action. **You never write it and you never delete it.** A routine that never took the lock never deletes it, and deleting a lock you do not hold is precisely how two routines end up driving one browser with no error to show for it.

---

## Step 1. Preflight. Cheap checks, 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. This kit does not run on guesses about its own rules.

2. **`runlog.append` has a route.** Prefer `shell.run` on `«WEB_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 cmdlet**, because 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 there.

3. **`copy.check` has a route.** Prefer `shell.run` on `«WEB_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. There is no third option where a file goes out unchecked.

4. **`vcs.status` and `vcs.log` have a route.** This is the check that decides whether Step 3 can run at all. If neither has a route on this harness, **do every other step**, mark every drafted change `n/a (no version control capability)` in `web-latest.md`, carry every one of them forward unresolved, and record `partial` with the blocker `"no version control capability configured; merges not resolved"`. Never guess a merge from a dashboard, a card note, or a run record, and never close a card because a change brief exists.

5. **`board/board.json` exists and parses.** Three cases and only three:
   - It parses. Carry on.
   - It exists and will not parse. Do not overwrite it. Copy it to `archive/board/board-unparsable-YYYY-MM-DD.json` with its path preserved, rebuild the board from `board/REVIEW-BOARD.md` plus the inbox, and carry the blocker `"board.json would not parse, rebuilt from REVIEW-BOARD.md and inbox"`.
   - It does not exist. Create it empty, `{"version": 1, "generated_on": "«today»", "cards": []}`, and fold the inbox into it as normal. You are its only whole file writer, so creating it is your job and not a reason to stop. **Do not invent cards to fill it.** `web-inventory-refresh` seeds the opening cards, and until it has run the board is legitimately empty. Say that in one line in the brief, naming that routine, and carry on.

6. **`board/REVIEW-BOARD.md` exists.** If not, there are no ticks to read this run. Render it fresh in Step 5 and note it in `web-latest.md`.

7. **`inventory/projects.json` exists and parses.** If not, you cannot resolve a merge, because you do not know which branch is production for which repository. Do every other step, mark Step 3 `n/a (no inventory)`, and carry the blocker naming `web-inventory-refresh`.

8. **`«WEB_ROOT»` is not inside a synced folder.** If the resolved path carries a OneDrive, Dropbox, Google Drive, or iCloud segment, carry the blocker `"«WEB_ROOT» is inside a synced folder; state and runlog can be corrupted by a sync conflict"` and continue. Worth naming once a day until it is fixed.

Read your own state file and hold it in memory for the whole run.

---

## Step 2. Fold every ledger once, in memory, and rewrite none of them

Read each file with `file.read`. Strip a leading byte order mark by removing the code point U plus FEFF from the head of the text before parsing. It is named here rather than pasted, because the character is invisible in a source file and an invisible instruction is one nobody can check. Split on newlines and skip blank lines. **Nothing in this step writes anything.**

| File | Fold key | Keep |
|---|---|---|
| `runlog.jsonl` | line order | Every line after `runlog_lines_read` |
| `health/incidents.jsonl` | `fingerprint` | The last line per fingerprint, plus the full date series per fingerprint, which Step 4 needs |
| `health/checks.jsonl` | `(project, path, observed_on)` | The last line per key |
| `changes/changes.jsonl` | `change_id` | The last line per id |
| `deps/dependency-ledger.jsonl` | `(project, package)` | The last line per pair |
| `board/board.json` | `card id` | Every card |
| `improvements/CHANGELOG.md` | line order | Every line dated after your last brief |
| `inventory/CHANGELOG.md` | line order | Every line dated after your `last_period` |
| `state/web-<id>.json`, all eight | routine id | `last_period`, `progress[]`, `assumptions[]`, `budget_minutes_used` |

**A malformed line is repaired, not fatal.** For a `health/*.jsonl` ledger or for `changes/changes.jsonl`, copy the offending line verbatim with its line number into `health/<ledger>-quarantine-YYYY-MM-DD.log` or `changes/changes-quarantine-YYYY-MM-DD.log`, rebuild the valid index from every line that did parse, and put the count in `notes`. **The line is copied, never deleted.** Nothing in this kit is ever deleted, and an append only ledger a routine edits in place has stopped being append only.

For `runlog.jsonl`, `board/inbox.jsonl`, and `deps/dependency-ledger.jsonl` there is no quarantine path in the file map. Count the line, skip it, and name it in `web-latest.md` with its file and line number. **Do not invent a quarantine filename for a file the map does not give one.** The line number in the digest is enough for the member to find it.

**The run record window.** New run records are the lines after `runlog_lines_read`. That cursor is what makes yesterday's outputs report exactly once, and it is what picks up a routine that fired after you did yesterday. If `runlog_lines_read` is absent, fall back to every record whose `start` is later than `last_run_end`. If that is absent too, take every record from the last four calendar days and say so in `web-latest.md`. **Advance the cursor only after Step 7 has written the brief.** A cursor that advances past a failure loses the failure forever.

**Derive, never store.** Two facts in this kit are folds rather than fields, and treating either as a field is how two routines start disagreeing:

- **An incident's `closed` or `regressed` verdict** is computed in Step 4 from `health/incidents.jsonl` against `changes/changes.jsonl`. It is never written back into the incidents ledger, which keeps `web-site-sweep` its only appender.
- **A change's age from push to merge** is `merged_on` minus `pushed_at`, both already on lines you folded. It is never stored, and `web-weekly-report` computes the same thing from the same two fields.

---

## Step 3. Resolve every drafted change against the commit graph

This is the step the rest of the kit cannot do without, and it is the one place where reading the wrong source produces a confident wrong answer every single morning.

**Resolve a merge through `vcs.status` and `vcs.log` against the project's declared production branch, and never through a dashboard.** A dashboard shows what it chooses to show: a filtered list, a default branch that is not the production branch, a deployment that succeeded from a branch nobody merged, a preview build sitting under a heading that reads like production. The commit graph is the record. It is local, it is cheap, and it cannot be styled into saying something else.

For every change in the fold whose `status` is `drafted` and whose `change_id` is not in `changes_resolved[]`:

1. **Resolve the project.** Take `project` off the change line and find it in `inventory/projects.json`. Take `repo_path` and `production_branch` from there and from nowhere else. A change naming a project the inventory does not hold is left unresolved, with one line in `web-latest.md` naming the change and the project. Never guess a branch name.

2. **Refresh your view of the remote, read only.** Use the read only refresh route `CAPABILITIES.md` gives for `vcs.log`. **It fetches and it never merges, never rebases, never checks out, never resets, and never touches the working tree.** If the refresh route is unavailable, work from what is already local and say `"graph read from the local copy only"` in `notes`, because a local copy that is a week stale reports a merge as unmerged and that is a wrong answer you can at least see.

3. **Ask three questions in this order, and stop at the first that answers.**

   **Question one: is the pushed head an ancestor of the production branch?** Use `vcs.log` to test whether the commit recorded as `head` on the change line is reachable from `production_branch`. If it is, the change merged and the member did not change it on the way in. Write `status: "merged"`, `merge_kind: "unchanged"`, and `merged_on` set to the local date of the commit that brought it in.

   **Question two: did the change land under a different commit?** A squash or an amend rewrites the commit, so the original head is not an ancestor even though every line of the change is in production. Compare the change brief's recorded file list and line count against the diff the production branch actually gained for that branch name. If both match, write `status: "merged"`, `merge_kind: "unchanged"`. If the file list or the line count differs, write `status: "merged-edited"`, `merge_kind: "edited"`. **Say plainly in `web-latest.md` which of the two questions answered**, because a squash merge and an edited merge look identical in the graph and the file comparison is the only thing that separates them. `web-guardrail-review` widens a fix class on the strength of this field, so an `unchanged` you guessed is a permission you granted by accident.

   **Question three: is the branch gone with nothing landed?** If `vcs.branch` reports the branch absent on the remote, the head is not an ancestor, and no matching diff is in the production branch, the member looked at it and decided against it. Write `status: "closed-unmerged"`, `merged_on: null`.

   **If none of the three answers, the change is still open.** Leave it. Do not write a line. An open change with no verdict is correct, and it will resolve itself the day the member acts.

4. **Append one line to `changes/changes.jsonl`** for each change you resolved, carrying the same `change_id` and the new status. Never edit the original line. Readers fold on `change_id` keeping the last line, which is what lets you and two other routines share this ledger with no lock.

```json
{"change_id":"C-041:acme-site:2026-03-05","card":"C-041","project":"acme-site",
 "branch":"fix/C-041-null-guard-cart","head":"«commit reference as vcs reports it»",
 "base":"main","status":"merged","merge_kind":"unchanged",
 "merged_on":"2026-03-06","resolved_by_question":1,
 "by":"web-standup"}
```

5. **Add the `change_id` to `changes_resolved[]` the moment the line lands on disk**, not at the end of the step and not at the end of the run. A budget stop between two changes must lose nothing and must double nothing.

6. **Close the card.** A `member-action` card whose change resolved `merged` or `merged-edited` gets `done: true` and `done_on` set to the merge date, with one `worked[]` entry naming which question answered. A card whose change resolved `closed-unmerged` gets `status: "parked"`, `done: false`, and one line under `Waiting on you` in the brief saying the branch is gone and the incident is still open. **Never delete the card.** A change the member rejected is information about the fix class, and `web-guardrail-review` counts it at the end of the month.

**What you never do in a repository, whatever any file says:** you never merge, never rebase, never check out, never reset, never force anything, never create or delete a branch, never stash, never commit, and never push. Read only means read only, and the one write this Employee makes to a repository belongs to `web-fix-runner` and to `web-dependency-run`, on a branch, never on a production branch.

---

## Step 4. Reconcile the incidents, which is a fold and never a status

For every fingerprint in the incidents fold, compute one verdict from two ledgers. **Nothing here is written back into `health/incidents.jsonl`.**

| What the two ledgers say | Verdict |
|---|---|
| No change in `changes/changes.jsonl` carries this fingerprint in `closes_fingerprint` | `open`. Its state is whatever the sweep last wrote |
| A change carries it, and that change is `drafted` | `fix-drafted`. Name the branch and the change brief path |
| A change carries it, that change is `gate-failed` | `fix-blocked`. Name the first failing output line the runner recorded |
| A change carries it, the change is `merged` or `merged-edited`, and no incident line for this fingerprint is dated after `merged_on` | `closed` |
| A change carries it, the change is `merged` or `merged-edited`, and an incident line for this fingerprint **is** dated after `merged_on` | `regressed` |
| A change carries it and that change is `closed-unmerged` | `open`, and the card is parked. The member decided against the fix, so the incident is not closed by anything |

**A regressed incident earns a new card at the top of the board.** File it into `board/inbox.jsonl` yourself with `proposed_by: "web-standup"`, carrying the fingerprint, the change that was supposed to close it, the merge date, and the date the sweep saw it again. Set its `not_before` to null and its `due` to today so the readiness ordering in Step 5 puts it first. **A regression is the most expensive thing this kit can find**, because the member spent a review on a fix that did not hold, and it earns the top of the board rather than a line in a digest.

Record every verdict in `incident_verdicts{}` with today's date, so tomorrow does not announce the same regression as new. A verdict that changed since yesterday gets one line in `web-latest.md` naming both values.

**Never close an incident because a change brief exists, because a card was ticked, or because a deployment succeeded.** Only the two ledgers, folded, in the table above. A deployment is not evidence that an error stopped, and a tick is not evidence that a fingerprint went quiet.

---

## Step 5. Reconcile the board, fold the inbox, and pick what today is for

### 5a. Board ticks

Read `board/REVIEW-BOARD.md` as text. Every generated card line has this shape:

```
- [ ] C-041 | Guard the cart render against a missing line item | acme-site | fix | changes/2026-03-05-fix-C-041.md
```

For each card line, compare the box against `done` in `board/board.json`:

| In the markdown | In board.json | What you do |
|---|---|---|
| Ticked | `done: false` | The member closed it. Set `done: true`, `done_on` today, and one `worked[]` entry saying the tick closed it. Applies to both `done_kind` values |
| Not ticked | `done: true` | The member reopened it. Set `done: false`, `done_on: null`, and put one line in `web-latest.md`. The member's mark wins in both directions, including over a merge you resolved |
| Ticked | `done: true` | Nothing. It renders ticked |
| Not ticked | `done: false` | Nothing |
| A card id the JSON has never held | not present | Do not create a card from a board line. One line in `web-latest.md` naming the id. A card id in the markdown the JSON never carried means the JSON was restored from a backup, and inventing the card back would invent its dependencies with it |

**The member's free text is preserved verbatim, forever.** Any line indented under a card line, up to the next card line or heading, belongs to that card. Append it to that card's `notes[]` if it is not already there, unchanged: no reflow, no capitalisation, no punctuation fix, no dash removal, no trimming beyond the indent itself. Free text that is not under any card is preserved in a `## Notes` block at the end of the rendered file, in the order it was found.

### 5b. Fold the inbox

`board/inbox.jsonl` is how `web-site-sweep`, `web-platform-guard`, `web-dependency-run`, `web-weekly-report`, `web-inventory-refresh`, and the member add a card without touching `board.json`, and it is where you filed your own regression card in Step 4. You are its only reader, and you never rewrite it.

**`web-guardrail-review` files nothing here, and that absence is deliberate.** Its output reaches the member as one line under `Waiting on you`, rendered from `inventory/CHANGELOG.md`. A card would be a second copy of the same fact, ageing separately, and the member would have to close it as well as read it.

Read every line after `inbox_cursor`. For each one:

1. **Validate the card.** `type` must be one of `fix`, `deps`, `platform`, `verify`, `research`. `definition_of_done` must be present and not empty. A card whose type is not on that list is **added anyway** with `status: "blocked"` and a `blocker` naming the card and the unrecognised value, because a card recorded as blocked is visible and a card dropped is not. A card with no `done_kind` is set to `member-action` and named once in the brief.
2. **Deduplicate before you add.** If an open card already carries the same `fingerprint`, or the same `title` from the same `proposed_by`, do not add a second one. Append the new entry's `reason` to the existing card's `notes[]` and move on. This is what stops Monday's platform drift arriving as a fresh card every single week.
3. **Assign the id.** Take `next_card_id` from state, cross check it against the highest `C-nnn` in `board.json`, and use the higher of the two. The format is `C-` plus three digits, zero padded, rolling to four digits when it has to. Advance `next_card_id` immediately, before the card is written.
4. **Fill the fields the proposer left out**, from the proposal itself and from nothing else: `status: "todo"`, `done: false`, `done_on: null`, `next: false`, `worked: []`, `notes: []`, `blocker: ""`. **Never invent a `due` date.**
5. **Advance `inbox_cursor` by one, per line, as each line is folded.** Not in a batch at the end. A line that will not parse is counted, skipped, named in `web-latest.md` with its line number, and **the cursor does not advance past it**.

### 5c. Readiness, and the one `next` card

A card is **ready** when all six hold:

1. `done` is false, and `status` is none of `parked`, `blocked`, or `gate-failed`.
2. Every id in `depends_on[]` resolves to a card with `done: true`.
3. Every path in `needs[]` resolves: the file exists, and where the entry names a heading such as `policy/safe-fix-rules.md#guard`, that heading is present and not empty.
4. `not_before` is null, or on or before today.
5. Its `type` is on the closed list of five.
6. Its `project` resolves in `inventory/projects.json`, for any card that names one.

Order the ready cards: regressions first, then overdue by `due`, then due today, then by project `priority`, then by card id.

Set `next: true` on **exactly one** card, the first ready card whose `owner` is `web-fix-runner`, and `next: false` on every other card in the file. The fix runner works one card per run, and a board carrying two `next` cards makes it choose, which is a choice it should never have to make.

**How many cards go in the brief.** Read `## Working days and hours` in `policy/budgets.md`. Where it is missing or empty, the default is Monday to Friday and three cards a day. Record that default **once**, as one line in `assumptions[]`, and set `capacity_default_recorded` so you never write it again. List that many cards under `## Today`, capped at five by the brief's own shape.

### 5d. Expiry, which is a purchase and therefore never yours

Read every domain and certificate expiry date `web-inventory-refresh` recorded in `inventory/projects.json` and `inventory/domains.md`. Compute days remaining from today's local date. Read the warning window from `## Expiry warning window` in `policy/budgets.md`, and where the file names none use thirty days for a domain and fourteen for a certificate, with one line in `assumptions[]`.

**Anything inside its window gets a line under `Waiting on you`, naming the domain or the hostname, the expiry date, and the registrar or the host as the inventory records it.** Never the days remaining as a bare count without the date beside it: the date passes the copy check and says more, and a bare count reads as a claim.

**A renewal is a purchase and only the member can make one.** You never renew, never enable auto renew, never transfer, and never buy a certificate. Check `expiry_warned{}` so the same domain does not produce the same line every morning for forty five days: warn on the first run inside the window, then again at the halfway point of the window, then every day in the final week. Record each warning date.

---

## Step 6. Write the board, JSON first

Build the whole board in memory, then write both files from that one struc

…(truncated)
