# Web Inventory Refresh

> Monthly, and on its first run it is the whole setup, launched by hand. It discovers what the member actually runs by walking their code roots and reading the registrar, the host, and the hosted database, then writes the inventory, the domains file, the starting budgets, and the opening safe fix rules, seeds the board, builds the dashboard, and registers one scheduled job per routine. It creates no account, buys nothing, and provisions nothing.

- Skill: `markfulton/web-inventory-refresh` (Agent Skill)
- Install (CLI): `npx skillmds@latest add markfulton/web-inventory-refresh`
- Raw SKILL.md: https://api.skillmd.com/api/skills/markfulton/web-inventory-refresh/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-inventory-refresh

---


# Inventory refresh

**Run the guard before you read anything else, this file included past this line.** Through `shell.run`: `node "«WEB_ROOT»/scripts/guard.mjs" web-inventory-refresh`. It reads `PAUSED`, your row in `SCHEDULE.md`, and `state/web-inventory-refresh.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 routine that knows what the member actually runs. Every other routine in this Employee reads what you write, so **a value you guessed is a value seven routines will act on for a month.**

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.

**You discover before you ask.** The member's repositories carry their own remotes, branches, manifests, build commands, rule files, and docs. Their provider accounts carry the hostnames, the environments, the log surfaces, and the expiry dates. Almost everything this Employee needs is already written down somewhere the member does not have to type it again. **You ask for exactly one value**, and only because no amount of reading can settle it: the working root path.

---

## The two guardrails, and the one question

**Guardrail 1, outbound actions, held unless released.** On a held channel you never create an account, never register, renew, or transfer a domain, never buy or provision a certificate, never upgrade a plan, never add a paid add on, never raise a usage tier, never provision paid infrastructure, and never put anything into a purchasable state including a cart, a saved order, a quote, or a scheduled plan change. You never merge, deploy, promote, redeploy, or restore anything. **Setting an Employee up must not cost the member money, and there is no step in this routine where spending would help.** 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 enter or generate a password, complete a captcha, enter payment details, or accept terms. **You record the names of environment variables and never their values**, not masked, not truncated, not by length. You never write a key, a token, a password, a connection string, or a URL carrying a credential into any file, any card, any run record, or any command.

**And the rule that belongs to this Employee: you never rotate or regenerate an encryption key or an API key.** You will pass several controls that offer to. Something is encrypted with that key or authenticating with it, and rotation destroys that thing silently.

**The one question.** On the first run you ask the member for `«WEB_ROOT»`, the working root path, and nothing else. It must be **a local folder that is not inside a synced folder** such as OneDrive, Dropbox, Google Drive, or iCloud, because `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. If the path they give is inside one, say so plainly, in one sentence, and ask once more. If the second answer is also inside one, use it, carry the blocker on every run, and put one line in `assumptions[]`. **A working Employee in a risky folder beats no Employee**, and the blocker is how they learn to move it.

**Everything else you research.** The code roots are found by looking. The build command is read from the manifest. The production branch is read from the repository and the host. The hostnames are read from the host. The expiry dates are read from the registrar. **Never ask for something a read can settle**, and never stall a first run waiting for an answer at whatever hour the member extracted the kit.

---

## Your files

### 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` | Every row, not only yours. You reconcile the table against the routine folders |
| `routines/*/SKILL.md` | The folder names and the YAML `name` keys, for the schedule reconciliation. **Never the bodies** |
| The member's code roots, through `file.list` | Every repository under them |
| Each repository's remote, branches, manifest, lockfile, rule file, and docs folder | Through `vcs.status`, `vcs.log`, `vcs.branch`, and `file.read` |
| `inventory/projects.json` | The previous month's copy, on every run after the first |
| `policy/budgets.md` | The previous copy, so the member's own sections are carried across verbatim |
| `health/checks.jsonl` | On a monthly run, to set a performance budget from what the site actually does today |
| `board/board.json` | Read only, for the open card check |
| `state/web-inventory-refresh.json` | Your own memory |
| `state/browser-lock.json` | The mutex, before any browser work |
| `recipes/BROWSER-RECIPES.md` | The technique library, referenced by name and never re-explained here |
| `recipes/<flow>.json` where `owner` is `web-inventory-refresh` | One flow per provider surface. Absent on a first run, and you learn it |
| `VERSION`, `improvements/CHANGELOG.md`, and `state/kit-update.json` where it exists | On the monthly pass only, for the two checks in Step B3a |

### What you write

| Path | How |
|---|---|
| `inventory/projects.json` | Whole file, temp path plus rename, then read back and parsed. You are its only writer |
| `inventory/PROJECTS.md` | Whole file, rendered from the JSON you just wrote |
| `inventory/domains.md` | Whole file, temp path plus rename |
| `inventory/CHANGELOG.md` | Append only, newest at the top, one line per change with its evidence path |
| `policy/budgets.md` | Whole file, temp path plus rename, **with every member written section carried across verbatim** |
| `policy/safe-fix-rules.md` | **Created once, on the first run only.** `web-guardrail-review` owns it from then on |
| `board/inbox.jsonl` | Append only, the opening card set on the first run, drift cards monthly |
| `dashboard/build.mjs`, `dashboard/src/*`, `dashboard/index.html` | The single page dashboard, built from the projects you found |
| `SCHEDULE.md` | **A missing row added, and a `fire` time moved only to clear a lane collision you detected.** Nothing else |
| `schedule-commands.txt` | Every command you would have run, expanded, where `schedule.register` has no route |
| `run/<routine-id>` | One single line launcher per routine, written only where the scheduler needs the invocation in a file rather than inline |
| `state/kit-update.json` | Whole file, one writer, this routine, on the monthly pass. Step B3a |
| `improvements/contribution-draft-YYYY-MM.md` | Whole file, one writer, this routine, on the monthly pass, in a month that has one. Step B3a |
| `recipes/<flow>.json` where `owner` is `web-inventory-refresh` | Created through `learn-a-recipe`, kept true through `repair-a-recipe` |
| `state/web-inventory-refresh.json` | Whole file, temp path plus rename |
| `runlog.jsonl` | Exactly one record, through `runlog.append` |

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

- **`policy/safe-fix-rules.md` after the first run.** `web-guardrail-review` owns it and rewrites it monthly from a month of the member's own merge decisions. **On a monthly run you read it and you do not touch it**, even where a class looks obviously wrong, because your evidence is what exists and its evidence is what the member did.
- **Any member written section of `policy/budgets.md`.** See Step B2. A value the member typed is not research output.
- **`board/board.json` and `board/REVIEW-BOARD.md`.** Your route to the board is `board/inbox.jsonl`. You never tick a card.
- **`brief-latest.md`, `briefs/*`, and `web-latest.md`.** `web-standup` owns all three, except the emergency route in Step 1.
- **`health/*`, `changes/*`, `deps/*`, `platform/*`, and `reports/*`.** Five other routines own those and you read only the one you need.
- **A `days`, `key`, or `budget` value in `SCHEDULE.md`, and you never remove a row and never set `days` to `off`.** Those are the member's.
- **Any other routine's `state/web-<id>.json`, and any recipe whose `owner` names another routine.**
- **Any file inside any of the member's project repositories.** You read them. You never write one, never create a branch, never commit, and never change a checked out branch.
- **Any object in any provider account.** An account is not a file, so it is not on this list, because it is not on any list.

---

## Step 0. The five opening lines

Not after reading anything else. First.

### 0.0 The pause switch

`file.read` `«WEB_ROOT»/PAUSED`. If the file exists and is either empty or names `web-inventory-refresh` 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 remembered from a previous run.** If `clock.local` has no route on this harness, 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-inventory-refresh`. 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.** Two facts are properties of this routine: it runs on the first weekday of the month, and its browser lane is `light`.

- Row missing or will not parse, **on any run except the first**: append one run record, `status: "failed"`, `blockers: ["no SCHEDULE.md row for web-inventory-refresh"]`, exit.
- Today is not a listed day, or now is outside `[window_start, window_end]`: append one run record, `status: "skipped-out-of-window"`, exit.

**The one exemption in this kit, and it is the only one.** On its very first run, identified by `state/web-inventory-refresh.json` not existing at all, this routine **skips the window check and only the window check**, and records `first run, window guard not applicable` in `notes`. The member launches it by hand at whatever hour they opened the folder, so there is no window to be inside, and a missing `SCHEDULE.md` row is the work it is about to do rather than a failure.

**The exemption covers the window check and nothing else.** The pause switch, the period guard, the budget, the mutex, and both stops all apply in full, on the first run and on every run after it. **No other routine in this kit has a first run exemption of any kind.**

`first-weekday` means any Monday to Friday date in the first seven days of the calendar month. It is a range rather than a single date so that a machine asleep on the first still gets its monthly run, and the period guard reduces the range to exactly one run per month.

### 0.2 The once per period guard, written before any work

This routine's period key is the calendar month, `YYYY-MM`, taken from the local date.

```
Read «WEB_ROOT»/state/web-inventory-refresh.json.

If it does not exist at all:
    this is the first run. Path A.

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, temp path plus rename, with last_period set to this key,
    started set to the ISO 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. **On the first run, write it the moment the root path is confirmed**, before a single file is created, so a second hand launch in the same minute exits clean rather than building the tree twice.

| Field | What it holds | What is lost if you drop it |
|---|---|---|
| `root` | The confirmed working root path and whether it is inside a synced folder | The member is asked the one question again every month |
| `code_roots[]` | The roots you walked and the date | Discovery restarts from scratch and may find a different set |
| `projects_known[]` | Project ids you have written before | Every project reports as new every month in the changelog |
| `first_run_on` | The date of the first run | The dashboard cannot say when the Employee started |
| `schedule_registered[]` | `{routine, route, registered_on}` per job | Eight jobs are registered a second time |
| `member_sections{}` | The heading names in `policy/budgets.md` the member has written under | **A member's own budget value is regenerated and lost** |
| `recipes[]` | The flow files you own and last touched | Only a convenience, but the standup reads it |
| `cards_filed[]` | `{subject, filed_on}` per opening or drift card | The opening card set is seeded a second time |
| `installed_employees[]` | Which sibling AI Employees the member has, captured at setup | The handoff section guesses |
| `contribution_cursor` | The date up to which you have read `improvements/CHANGELOG.md` for Step B3a | The same repairs are drafted for sending back a second time |

**Never process an item whose date is not the current period key. There is no backlog flushing in this kit, ever.**

### 0.3 The wall clock budget

Record the start time from `clock.local`. Take `budget` from the `SCHEDULE.md` row. **On the first run, where no row exists yet, use the budget the shipped table in `SCHEDULE.md` carries for this routine, and where the file itself is absent, take forty five minutes and record one line in `assumptions[]`.**

Check the clock **between units of work**: per repository walked, per provider screen read, per file written, per job registered. Never only per phase.

**Reserve the last fifth of the budget for the close out, and on the first run reserve it for Step A9 and Step A10.** A first run that discovered everything and registered no jobs has produced a folder nobody will ever open again, because nothing will fire.

Append to `progress[]` the instant each unit completes, so a stop resumes rather than restarts. **On the first run the order in Path A is chosen so that a budget stop leaves something usable**: the tree, the inventory, and the policy files come first, because everything else reads them.

**The dashboard at Step A8 is the first thing cut when the budget is tight, and the schedule registration at Step A9 is the last.** A scheduled kit with no dashboard works and produces a brief tomorrow morning. A dashboard with no schedule is one page nothing will ever update. If Step A8 would eat the reserve, skip it entirely, record one line saying the dashboard was not built, and go straight to Step A9.

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

### 0.4 The browser mutex

Your lane is `light`. You open provider screens to bind repositories to what they actually run on, so **you take the lock**.

- **The lock is taken at the top of Step A4 on a first run, and at the top of Step B1's provider pass on a monthly run.** Never in Step 0, and never before the local discovery is done, because the local walk is most of the work and holding the lane through it blocks four other routines for nothing.
- **Release it** in the close out block, in the same block that writes the run record, on every exit path without exception.
- **If you never took it, you never delete it.**

Section 6 of `CONTRACT.md` is the procedure and it is identical in every routine that has a lane.

---

## Step 1. Preflight, and which run this is

1. **`CONTRACT.md` and `ROLE.md` readable.** If not, `status: "failed"`, blocker naming the file, exit. **On a first run, if they are not there, the kit was extracted incompletely**: say that in one sentence and stop.

2. **`runlog.append` has a route.** Prefer `shell.run` on `«WEB_ROOT»/scripts/runlog.mjs`. If unavailable, take the in agent route and put `runlog: in-agent` in `notes`. **Never append through a shell redirect or an append cmdlet.** If neither route exists, write the record as the last line of `brief-latest.md` under a heading `UNRECORDED RUN`, and stop.

3. **`copy.check` has a route.** Confirmed once with `--selftest`. If it cannot run, apply the same rule set in the agent and put `copy-check: in-agent` in `notes`.

4. **`file.list` and `file.read` have routes.** Without both there is no discovery. `failed`, blocker naming them, exit.

5. **`«WEB_ROOT»` is not inside a synced folder.** On every run, carry the blocker if it is. On the first run this is the one thing you push back on before accepting the answer.

**Then decide which run this is**, and it is one line: `state/web-inventory-refresh.json` does not exist means **Path A, the first run**. It exists means **Path B, the monthly pass**. Nothing else decides it, and a partly built tree does not: a first run that stopped at budget wrote its state file at Step A1, so its resume is a Path B run that finds a great deal missing, which is exactly what Path B is built to handle.

---

# PATH A. The first run

## Step A1. Ground the run, and ask the one question

1. **Ask for the working root path.** One question, one sentence of why, and the constraint stated plainly: a local folder outside any synced folder, because state files written mid run get corrupted by a sync conflict and that is the file that tells tomorrow's run what already happened.
2. Confirm the path exists or can be created. Create it if it does not.
3. **Write `state/web-inventory-refresh.json` now**, with `last_period` set to this month, `root` set to the confirmed path, and `first_run_on` set to today. Before anything else is created. A second hand launch in the same minute must exit clean.
4. **Ask nothing else, ever, on this run.** Every other value is discovered below, and where discovery fails the answer is a `research` card and a recorded assumption rather than a question into an empty room.

## Step A2. Build the tree

Create every folder the file map names, each one empty, and each one created before anything writes into it:

```
inventory/  policy/  health/  board/  changes/  deps/  platform/  reports/
briefs/  recipes/  scripts/  state/  improvements/  archive/  dashboard/src/pages/
```

Create `improvements/CHANGELOG.md` with its heading and nothing else. Create `board/inbox.jsonl` empty. **Do not create `PAUSED`**, ever: it is the member's file and its absence is what means the Employee is running.

Confirm `scripts/runlog.mjs` and `scripts/copy-check.mjs` are present and each answers `--selftest`. **Where one is missing, do not write a replacement.** Record the blocker naming it, use the in agent route for that capability, and say so in the session report. A script you wrote yourself is a script nothing audited.

## Step A3. Walk the code roots

**Find the repositories by looking, not by asking.**

1. **Resolve the candidate roots.** Take, in order: any root the member's own workspace rule file names, the parent of `«WEB_ROOT»` where it holds repositories, and the member's usual project folder if one is discoverable from the machine's own conventions. **Never walk a whole drive**, and never walk a synced folder looking for repositories: cap the walk at a stated depth and a stated file count, and record both in the session report.
2. For each candidate root, `file.list` for repository markers.
3. **Cap the discovery.** Take at most the number of projects the budget can actually bind, and where more exist, take them in order of most recently modified and record the rest by name in the session report as `not yet in the inventory`. **A partial inventory that is honest about being partial is worth more than a complete one that timed out halfway through binding.**

For each repository found, read these through `vcs.status`, `vcs.log`, `vcs.branch`, and `file.read`, and **record only what you actually read**:

| Field | How you get it | If you cannot |
|---|---|---|
| `remote` | The remote as the repository reports it | `null`, and a `research` card |
| `default_branch` | The branch the remote reports as its head | `null`, and a `research` card |
| `production_branch` | **The default branch, unless the host says a different branch deploys to production, in which case the host wins.** Step A5 confirms it | The default branch, with one line in `assumptions[]` |
| `branch_convention` | From the project's own rule file, where it states one | `fix/<card-id>-<slug>` and `deps/<date>-<project>`, with one line in `assumptions[]` |
| `package_manager` | **From the lockfile the repository actually holds**, and where two lockfiles exist, from the one the rule file names | `null`, and a `research` card. **Never guess**, because running the wrong manager rewrites the whole tree |
| `build_command` | The build script the manifest declares | `null`, and a `research` card |
| `test_command` | The test script the manifest declares | `null`. **Never substitute the build command**, and never invent one |
| `rule_file` | The rule file the repository holds, whatever it is called | `null` |
| `docs_dir` | The docs folder the repository holds | `null` |

**Never run a repository's build or test command during discovery.** You are recording that a command exists, not that it works. A build during setup can take twenty minutes and can write into the tree.

**Never change a checked out branch and never touch a working tree.** Where a repository has uncommitted changes, record it, bind it as normal, and say so in the session report.

## Step A4. Read the three provider surfaces

**Take the browser mutex here**, per Step 0.4. Read `state/browser-lock.json`, write it if absent, defer if a live lock holds it, overwrite it if it is stale and note that.

Then `browser.tab.open` your own tab, follow `tab-hygiene`, and reuse that one tab. Every screen below is read only: **you navigate, you read, and you set a view control. Nothing else.**

Where a flow file is not there, follow `learn-a-recipe`: drive it once, read back the one string that proves you are on the destination view before you write each step down, write only what you verified, and carry on in this same run. **A flow file never records a control that saves, applies, deploys, rotates, renews, or buys**, because no run is ever allowed to execute one.

### A4a. The host

Read the project list. For each project, read: its name as the host lists it, the branch it deploys to production from, its custom hostnames with their certificate expiry dates, its environments, **the names of the environment variables in each environment**, and the screen where its runtime log lives.

**Names only, never values.** Not masked, not truncated, not by length. A screen offering to reveal a value is a screen whose reveal control you do not press.

### A4b. The registrar

Read the domain list. For each domain: its expiry date exactly as the registrar states it, its auto renew state read off the domain's own screen rather than a list row, its nameservers in use, and the registrar's own name.

**Never compute an expiry date from a registration date and a term length.** A renewal already applied makes that arithmetic wrong by a year in the direction that matters.

### A4c. The hosted database

Read the project list. For each: its name as the service lists it, and the screen where its log lives.

### A4d. Bind them

Match each repository to a host project, a database project, and a set of domains. **Bind on evidence, in this order:** the host project's own connected repository, then an exact name match, then a hostname that appears in the repository's own configuration. **Where none of the three matches, leave the binding null and file a `research` card.** A binding you guessed sends every later routine to the wrong log surface, and the failure is silent.

Where the host says a different branch deploys to production than the repository's default branch, **the host wins** for `production_branch`, and you record one line in `assumptions[]` naming both.

## Step A5. Write the inventory

`inventory/projects.json`, whole file, temp path plus rename, then read back and parsed before anything else reads it.

```json
{"version": 1, "generated_on": "2026-03-02", "projects": [
 {"id": "acme-site", "name": "Acme marketing site", "priority": 1,
  "repo_path": "«absolute local path»",
  "remote": "«remote as the repository reports it»",
  "default_branch": "main", "production_branch": "main",
  "branch_convention": "fix/<card-id>-<slug>",
  "package_manager": "«as read from the lockfile»",
  "build_command": "«as declared in the manifest»",
  "test_command": "«as declared in the manifest, or null»",
  "rule_file": "«the rule file this repository holds»", "docs_dir": "docs",
  "public_paths": ["https://«host»/", "https://«host»/pricing"],
  "tracked_path": "https://«host»/",
  "hostnames": [{"host": "«hostname»", "certificate_expires_on": "2026-05-11"}],
  "host_project": "«name as the host lists it»",
  "environments": ["production", "preview"],
  "required_env_names": ["«NAME»"],
  "database_project": "«name as the service lists it»",
  "log_surfaces": [{"kind": "host-runtime", "screen": "«URL»"},
                   {"kind": "database", "screen": "«URL»"}],
  "domains": [{"domain": "«domain»", "expires_on": "2027-01-04",
               "auto_renew": true, "registrar": "«registrar name»"}]}
]}
```

`id` is a slug of the repository name, stable forever. **Never change an id once written**, because every ledger in this kit keys on it and a changed id orphans a project's whole history.

`public_paths` starts as the site root plus any path the repository's own routing declares as a top level page, capped at four per project. **More paths is more page loads every weekday**, and four honest ones beat twelve that eat the sweep's budget.

`tracked_path` is the site root unless the rule file names a different page as the one that matters.

`required_env_names` holds **only the names that appear in every declared environment**. A name present in one environment and not another is not required, it is a difference, and it goes in as a `research` card rather than as a requirement that will alarm the platform guard every Monday.

Then render `inventory/PROJECTS.md` from the JSON you just wrote, one section per project, every field on its own line, so the member can read it without opening JSON. And write `inventory/domains.md`, one line per domain: the domain, the registrar, the expiry date, the auto renew state, and the project it belongs to.

## Step A6. Write the starting policy

### `policy/budgets.md`

```
# Budgets and thresholds

## Performance budgets
one line per tracked path: «project» | «path» | «metric» | «budget» | «how it was set» | «date»

## Incident threshold
count: 5

## Expiry warning window
domains: 30 days
certificates: 14 days

## Page load caps
one line per project: «project» | «n» page loads per sweep run

## Replay cap
6 flows per weekly report run

## Branch push cap
3 branches per dependency run

## Guardrail review
consecutive_clean_merges_to_widen: 3

## Working days and hours
mon-fri 09:00 to 18:00

## Member set
Anything you write under this heading is yours and is carried across every
rebuild of this file, word for word.
```

**On a first run there is no `health/checks.jsonl` to set a performance budget from**, so every budget row reads `«metric» | not yet measured | set from the first four weeks of checks | «date»`, and the first monthly pass fills it. **Never invent a performance number**, and never copy one from a general recommendation: a budget the member's site has never met is a budget every report fails against for no reason.

### `policy/safe-fix-rules.md`

**Created once, here, and owned by `web-guardrail-review` from this moment on.** Deliberately narrow, because an Employee that starts wide and narrows has already made the mistakes it is narrowing away from.

```
# Safe fix rules

web-fix-runner may change, without filing a question first, exactly what this
file allows and nothing wider. web-guardrail-review rewrites this file at the
end of each month from what you actually merged.

## Rungs
off                   nothing unsupervised. Write it up with a proposed diff
one-file              one file, at most the line count below
one-project           any files inside one project, at most the total below
one-project-plus-test the same, plus the test that covers the change

## Classes
content   one-file    12 lines
config    off
dependency one-project 400 lines   (owned by web-dependency-run, patch class only)
guard     one-file    20 lines
logic     off
schema    off
infra     off

## Never tuneable, at any rung, on any evidence
Nothing is merged into a production branch. Nothing is deployed. No key or
token is ever rotated or regenerated. No purchase is made in any state.
```

**Every class not named here is `off`.** An absent boundary is the narrowest boundary, always, in both directions.

## Step A7. Seed the board

Append the opening card set to `board/inbox.jsonl`, one line each, deduped against `cards_filed[]`. Seed exactly these, and nothing invented on top:

1. One `research` card per project field you could not read, naming the field and the file it belongs in. `done_kind: "local-artifact"`, `owner: "web-inventory-refresh"`, because you write it yourself next month once the member has told you.
2. One `research` card per project with no `test_command`, because that is the single field whose absence makes every future change riskier.
3. One `platform` card per domain or certificate already inside its warning window, `done_kind: "member-action"`, `owner: "member"`, carrying the exact date and the exact screen.
4. One `research` card if `«WEB_ROOT»` is inside a synced folder.
5. One `research` card if any project you found was left out of the inventory by the discovery cap, naming them.

**Nothing else.** A first run that seeds twenty cards hands the member a backlog on day one, and a backlog is what they were paying not to have.

## Step A8. Build the dashboard

One page, built from the projects you found, dependency free, no install step, and no package file.

`dashboard/build.mjs` reads the partials under `dashboard/src/pages/`, the shared shell in `dashboard/src/`, and writes `dashboard/index.html` as one self contained file. **No external fetch, no content delivery network reference, and no external font.** It runs through `shell.run` and it takes no arguments.

The tab set comes from the projects you found: one tab per project, plus an overview tab. Each project tab renders its inventory fields, its hostnames with their expiry dates, and one line naming the two files that carry today's numbers, `brief-latest.md` and `health/health-latest.md`.

**You are the only writer of this page and no routine fills a count into it.** It renders what the inventory holds and it names where the live numbers live, so a member reading it can never be looking at a stale figure that nothing told them was stale. A dashboard that quietly holds last month's incident count is worse than one that points at the file holding this morning's.

**Every word on that page is addressed to the member.** No design notes, no rationale, no next steps for an agent, no explanation of how the Employee works. Run `copy.check --dest plain` over each partial before the build and over `dashboard/index.html` after it.

Build it, then **read `dashboard/index.html` back off disk and confirm it is a single file, that it opens with a document type declaration, and that it references no external host.** A dashboard you built and did not read back is a dashboard you are guessing about. If the build fails, record the blocker, leave the partials in place, and carry on: the schedule matters more than the dashboard and Step A9 is still ahead.

## Step A9. Reconcile the schedule and register the jobs

### A9a. Enumerate, never assume

`file.list` the `routines/` folder. **The routine ids are the folder names on disk**, and for each one confirm the YAML `name` key inside its `SKILL.md` equals the folder name. **A folder whose name and `name` key differ is broken and it is named as a blocker rather than registered**, because a routine that cannot find its own row fails on its first line, forever, with no error the member ever sees.

### A9b. Reconcile rows against folders

| What you find | What you do |
|---|---|
| A folder with no row in `SCHEDULE.md` | **Add the row.** Place it by A9c, record it in `inventory/CHANGELOG.md` |
| A row with no folder | **Leave it.** Record one line in the session report. Never remove a row: a member may be about to add that routine back |
| A row whose `fire` time collides with another browser capable routine's budget plus twenty minutes | **Move the `fire` time, and only for that reason.** Record both times in `inventory/CHANGELOG.md` with the collision as the evidence |
| Anything else about a row | Leave it. `days`, `key`, and `budget` are the member's |

### A9c. Placing a row

Two rules and they are the whole arithmetic:

1. **The minimum gap between two browser capable fires is the earlier routine's full budget plus twenty minutes.** Use the budget, never the typical run time. A routine budgeted for thirty five minutes will one day take thirty five minutes.
2. **No two routines share a fire minute**, even ones that never touch a browser. Hosts flush queued jobs in bursts, and two agent sessions starting in the same second compete for the same files.

**One constraint is load bearing and it is not arithmetic:** `web-guardrail-review` must fire after `web-weekly-report`'s full budget has elapsed on a month whose last weekday is a Friday, so it reads that week's report rather than racing it. Check that gap after any move you make and never close it.

### A9d. Register

Take whichever route `CAPABILITIES.md` says exists on this machine: the harness's own scheduler first, the operating system's scheduler through `shell.run` second.

1. **One job per routine.** Eight routines, eight jobs. **Never one job that runs several in sequence**: a chained job defeats the per routine period guard, blurs the budgets, and turns one failure into eight.
2. **The job's only content is the invocation** that runs one routine unattended in `«WEB_ROOT»`. All the logic is in the `SKILL.md`.
3. **Prove one routine by hand before you register eight.** Run `web-standup` and watch it write `brief-latest.md` and one line into `runlog.jsonl`. **Eight jobs registered on an invocation nobody has run is eight silent failures on the same morning**, and the first thing the member sees is an empty brief.
4. **Register the `fire` column, not the window.** The window is enforced inside the routine.
5. **Name every job exactly after its routine id**, so the monthly drift check can match a job to a row.

Record each one in `schedule_registered[]` with its route and the date.

**Where no route exists at all**, write every command you would have run into `«WEB_ROOT»/schedule-commands.txt`, **expanded, with every path and every value filled in**, because a file the member has to translate before running is not a recovery path. Name that file in the session report and file one card so `web-standup` surfaces it under `Waiting on you`. **The kit runs identically whether a scheduler or a person started the run.**

## Step A10. Close the first run

Write the session report to the member, in the session itself and not to a file they have to go and find. **Every word of it addressed to them**, and it says: how many projects were found and bound, which fields could not be read, which cards are waiting, where the dashboard is, whether the jobs registered and by which route, and what happens tomorrow morning.

**Never write a push on a first run.** Setup is noisy by nature and the member is sitting there watching it.

Then go to the close out block, which is the same one Path B uses.

---

# PATH B. The monthly pass

## Step B1. Read the same evidence again

**Re-read, never trust.** Walk the code roots again from `code_roots[]`, read each repository's remote, default branch, manifest, rule file, and docs folder again, and take the browser mutex and read the three provider surfaces again exactly as Step A4 does.

**A value that is still true costs one read to confirm and a value that changed costs the member a month of a routine acting on the old one.** The most common changes, and each one breaks something specific:

| What changed | What breaks until you catch it |
|---|---|
| A repository's default branch moved | `web-standup` resolves every merge against a branch nothing lands on, so no card ever closes |
| A hostname gained or lost a certificate | The expiry ladder warns about the wrong thing, or about nothing |
| A project's build or test command changed | Every gate runs the wrong command, and a green gate means nothing |
| A log surface moved | `web-site-sweep` reads no errors and reports a quiet week |
| A package manager changed | The dependency run rewrites a tree with the wrong tool |
| A domain was renewed | The expiry date is a year stale and the warning never fires, or fires forever |
| A project was added or removed | It is swept and never worked, or worked and never swept |

**Also read on this pass:** `VERSION`, `improvements/CHANGELOG.md`, and `state/kit-update.json` where it exists, for the two checks in B3a.

## Step B2. Apply what changed, and carry the member's words across

### What you change

Rewrite `inventory/projects.json`, `inventory/PROJECTS.md`, and `inventory/domains.md` whole from what you read this run. **Every project id that existed before still exists**, with the same id, even where the repository is gone: mark it and record it, never drop it, because every ledger keys on that id.

Rewrite `policy/budgets.md` whole, and **this is where the one rule that matters most in Path B lives.**

**Carry every member written section across verbatim.** Read the previous file first. Any heading listed in `member_sections{}`, plus everything under `## Member set`, plus **any line under any heading that differs from what you last generated**, is the member's own writing. **A value the member typed is not research output and it is never regenerated**, however wrong it looks to you. Copy it character for character, including its spacing, and record in `member_sections{}` that you did.

Everything else you regenerate:

- **`## Performance budgets`** from `health/checks.jsonl`: for each tracked path, the median sample across the last four weeks, plus a stated margin, recorded with `how it was set` reading `median of the last four weeks plus margin` and today's date. **Never tighten a budget the member has already met and never loosen one they miss.** Where a path has fewer than the observation floor of samples, leave the previous value and say `not enough samples thi

…(truncated)
