diff --git a/.claude/loops.md b/.claude/loops.md
index 09f4b550..70b02f6f 100644
--- a/.claude/loops.md
+++ b/.claude/loops.md
@@ -37,7 +37,8 @@ the PR is opened.** No exceptions.
**Destination:** GitHub Issues + PRs in `erp-mafia/accounted` (via `gh`). Not Linear.
**Labels:** `loop:auto` (always, on anything a loop creates), `loop:vercel`, `loop:triage`,
-`loop:design`, `loop:needs-human` (a loop tried and could not safely proceed).
+`loop:design`, `loop:docs`, `loop:regeluppdat`, `loop:needs-human` (a loop tried and could not
+safely proceed).
**Idempotency / anti-spam: MANDATORY.** Before filing anything:
1. Compute a stable **fingerprint** (error signature, file:line, rule id, never a timestamp).
@@ -62,6 +63,8 @@ comment what was tried. Never retry the same failing action in a cycle.
| 2 | Vercel errors → tickets | `loop-vercel-errors` | **Local** (Vercel MCP) | daily / on-demand | ≤8 issues, ≤2 PRs |
| 3 | Issue triage + easy-fix | `loop-issue-triage` | **Local** (session cron / `/loop`) | ~2x/day while a session is open | triage all; ≤2 PRs |
| 4 | UI/UX + design scan | `loop-design-scan` | **Local** (`/loop`) | on-demand | ≤1 area, ≤6 findings |
+| 5 | Docs freshness (docs.accounted.se) | `loop-docs-freshness` | **Local** (`/loop` / ignite-when-due) | weekly | 1 issue, 1 website PR |
+| 6 | Regeluppdat (Swedish regulatory watch) | `loop-regeluppdat` | **Local** (`/loop` / ignite-when-due) | monthly | ≤6 issues, **0 PRs** |
**All loops are local.** Cloud routines were retired 2026-07-20: the three claude.ai triggers
(`trig_01J2nG7eB9gsdAb9YSGBVwa8`, `trig_014CmE3gTJ7ErnvL2trPYymu`, `trig_017hB94ieGVwreJqHpGRDVoM`)
@@ -70,6 +73,12 @@ to disable them rather than provision. Do not re-enable or re-create them. Loop
the Vercel MCP (local-only; Sentry is not used). Loop 4 needs `npm run dev` + Chrome.
The `loop-ignite` skill audits and (re)schedules the local cadence each session.
+Loops 5 and 6 self-gate on a due check (a run marker in their tracking issues), so any invocation
+is idempotent: session crons cannot outlive 7 days, which makes weekly/monthly cadences
+unschedulable directly. Instead `loop-ignite` runs them **when due** at the start of a session.
+Loop 6 files tickets only, never PRs: regulatory changes touch money math and compliance logic,
+which the autonomy policy forbids auto-fixing.
+
---
## The verification gate (`loop-verify`)
@@ -95,7 +104,8 @@ of that deliberately, treat cloud as retired: see issue #993 for the full histor
## Operating the loops
- **Audit / (re)ignite each session:** `/loop-ignite` (audits evidence, schedules session-local crons).
- **Run on-demand:** `/loop-pr-ci-triage`, `/loop-issue-triage`, `/loop-vercel-errors`,
- `/loop-design-scan `. Wrap in `/loop ` to repeat locally; `/goal` for a hard exit.
+ `/loop-design-scan `, `/loop-docs-freshness`, `/loop-regeluppdat`. Wrap in
+ `/loop ` to repeat locally; `/goal` for a hard exit.
- **Cost:** route mechanical steps to cheaper models; reserve judgment for the strong model. `/usage`.
Don't run more often than the watched thing changes.
diff --git a/.claude/skills/loop-docs-freshness/SKILL.md b/.claude/skills/loop-docs-freshness/SKILL.md
new file mode 100644
index 00000000..c09652e6
--- /dev/null
+++ b/.claude/skills/loop-docs-freshness/SKILL.md
@@ -0,0 +1,97 @@
+---
+name: loop-docs-freshness
+description: Weekly loop that checks the public API docs (docs.accounted.se) against what this repo generates today, files one deduped drift issue, and proposes the re-export PR in the gnubok-website repo. Use on a weekly cadence or on-demand via /loop-docs-freshness. Follows .claude/loops.md (propose-don't-merge).
+---
+
+# loop-docs-freshness
+
+**Goal:** what docs.accounted.se serves is byte-identical to what this repo would generate today.
+The docs site lives in the separate `jakobwennberg/gnubok-website` repo and is fed by manual
+snapshot exports, so it silently drifts whenever an endpoint, error code, or content page changes
+here. **Never merge.** Read `.claude/loops.md` first.
+
+## Preflight
+
+`gh auth status`, repo `erp-mafia/accounted`. If it fails, stop and report "environment not provisioned".
+
+## 1. Due check (weekly semantics)
+
+Find the most recent trace: `gh issue list --label loop:docs --state all --limit 5 --json number,title,updatedAt`
+plus the newest `` marker in those issues' comments.
+If a run marker is dated **less than 6 days ago**, report "not due" and stop, unless the user
+invoked `/loop-docs-freshness` explicitly (explicit invocation always runs).
+
+## 2. Run the deterministic check
+
+From an up-to-date `main` checkout (the comparison is "what main generates" vs "what the site
+serves"; a feature branch would produce false drift):
+
+```bash
+npx tsx scripts/check-docs-freshness.mts
+```
+
+- **Exit 0** (in sync): if an open `loop:docs` drift issue exists, comment that the site is back
+ in sync and close it. Post the run marker (step 5). Done.
+- **Exit 2** (fetch/build failure): the site is unreachable or the content modules changed shape.
+ Do NOT file a drift issue. If this is the second consecutive exit-2 run, file/update one issue
+ labeled `loop:auto`, `loop:docs`, `loop:needs-human` describing the failure. Stop.
+- **Exit 1** (drift/missing): continue.
+
+## 3. File or update ONE drift issue (cap: 1 per run)
+
+The script prints a `FINGERPRINT` per drifted page. Compute the run fingerprint = first 12 hex
+chars of sha256 over the sorted page fingerprints, then dedupe per `.claude/loops.md`:
+
+- `gh issue list --search " in:body" --state all` : match open → comment the fresh
+ script summary instead of filing; match closed → reopen with a note.
+- Otherwise file **one** issue (never one per page):
+ - Title: `docs drift: docs.accounted.se out of sync ( pages)`
+ - Body: the script's non-ok output verbatim, the remediation steps below, and
+ ``.
+ - Labels: `loop:auto`, `loop:docs`, `documentation`.
+
+If an *older* open `loop:docs` issue has a different fingerprint (the drift changed), comment on
+it with a pointer to the new issue and close it: one open drift issue at a time.
+
+## 4. Propose the fix (cap: 1 website PR per run)
+
+Two failure classes, two remedies:
+
+- **Generated snapshots stale** (`reference*`, `errors.md`, `connect-claude.md`): run
+ `npx tsx scripts/export-docs-to-website.mts`. It writes `*.generated.ts` into
+ `~/gnubok-website`.
+- **Hand-copied pages stale** (`changelog.md`, `versioning.md`, `webhooks.md`, `cookbook/*`):
+ copy the exported markdown constants from `lib/docs/content/` in erp-base over the website
+ repo's duplicates in `lib/docs/content/`. Mechanical copy only; if the two sides have diverged
+ in structure (not just content), escalate with `loop:needs-human` instead.
+
+The `~/gnubok-website` checkout is often on a human's WIP branch. **Never commit on it.** Work in
+a fresh worktree from origin/main:
+
+```bash
+git -C ~/gnubok-website fetch origin
+git -C ~/gnubok-website worktree add /tmp/loop-docs-freshness-wt -b loop/docs-freshness- origin/main
+```
+
+Re-run the export/copy against the worktree, commit, push, and open a PR **in
+`jakobwennberg/gnubok-website`** whose body links the erp-base drift issue and embeds the same
+fingerprint marker. Then remove the worktree. Never merge; the founder deploys the website.
+
+**404s on pages whose routes exist on the website's origin/main** (seen 2026-08-09 for every
+`reference/.md` and `cookbook/.md` mirror) are a deploy/runtime problem, not a
+snapshot problem: a re-export PR will not fix them. Label the issue `loop:needs-human` and say
+so explicitly.
+
+## 5. Run marker + report
+
+Comment `` plus a one-line summary on the drift issue
+touched this run (or, when in sync and nothing is open, skip the marker comment and rely on the
+closed issue's timestamp). Summarize: pages in sync / drifted / missing, issue filed or updated,
+website PR opened, escalations. That summary is the completion notification.
+
+## Anti-thrash
+
+If a previous run already opened a website PR for the **same run fingerprint** and it is still
+open, do not open another: comment on the existing PR/issue instead. If the same fingerprint
+drifts again after its PR merged and deployed, something upstream regenerates differently:
+`loop:needs-human`.
diff --git a/.claude/skills/loop-ignite/SKILL.md b/.claude/skills/loop-ignite/SKILL.md
index e51b3814..bf69743d 100644
--- a/.claude/skills/loop-ignite/SKILL.md
+++ b/.claude/skills/loop-ignite/SKILL.md
@@ -21,12 +21,20 @@ audits. All loops run locally now.
- Local schedule: `CronList` for session-local jobs invoking `/loop-*` skills.
- Evidence of firing: recent `loop/*` branches, PR comments by the loop, `loop:needs-human` labels
(`gh pr list`, `gh issue list --label loop:needs-human`).
-2. **Report a one-screen status table**: loop, expected cadence, last observed run, verdict (LIVE / DEAD / NEVER RAN).
+ - Slow loops: last `` marker on `loop:docs` issues (due
+ weekly) and last `` marker on the `Regeluppdat scan log`
+ issue (due monthly).
+2. **Report a one-screen status table**: loop, expected cadence, last observed run, verdict (LIVE / DEAD / NEVER RAN / NOT DUE).
3. **Ignite what is dead.** For each non-live loop:
- Run the loop skill once NOW (`/loop-pr-ci-triage`, `/loop-issue-triage`, `/loop-vercel-errors`)
so the backlog is cleared this session.
- Then schedule a session-local cadence (`CronCreate` invoking the matching `/loop-*` skill) and
say clearly that it only runs while this session is alive (7-day auto-expiry).
+ - **Slow loops run when due, not on a cron**: session crons cannot outlive 7 days, so weekly and
+ monthly cadences cannot be scheduled directly. If the docs-freshness marker is older than 6
+ days (or absent), run `/loop-docs-freshness` once now; if the regeluppdat marker is not from
+ the current calendar month, run `/loop-regeluppdat` once now. Both self-gate, so running them
+ again is a cheap no-op.
- If a loop cannot run at all, file the blocker as a GitHub issue so the gap is visible instead of silent.
4. **Switch-on check (mandatory):** after igniting, verify via `CronList` that the jobs exist.
End with either "ALL LOOPS LIVE (session-local)" or "NOT SWITCHED ON YET: - ".
diff --git a/.claude/skills/loop-regeluppdat/SKILL.md b/.claude/skills/loop-regeluppdat/SKILL.md
new file mode 100644
index 00000000..42145478
--- /dev/null
+++ b/.claude/skills/loop-regeluppdat/SKILL.md
@@ -0,0 +1,106 @@
+---
+name: loop-regeluppdat
+description: Monthly loop that scans official Swedish sources for new or changed laws and rules affecting accounting/finance software (VAT, payroll, BFN standards, deadlines, BAS chart, e-invoicing), checks whether the codebase already handles each change, and files deduped GitHub issues for the gaps. Use monthly or on-demand via /loop-regeluppdat. Follows .claude/loops.md (propose-don't-merge, tickets only, no auto-fix).
+---
+
+# loop-regeluppdat
+
+**Goal:** no Swedish regulatory change that affects Accounted lands as a surprise. Each month:
+sweep the official sources, keep only what touches our product surface, verify against the code,
+and file a well-formed ticket for every real gap. **This loop never writes code.** Regulatory
+changes touch money math, tax logic, and compliance surfaces, which the autonomy policy forbids
+auto-fixing: tickets only. Read `.claude/loops.md` first.
+
+## Preflight
+
+`gh auth status`, repo `erp-mafia/accounted`. If it fails, stop and report "environment not
+provisioned". Web access (WebSearch/WebFetch) must be available; without it, stop and report.
+
+## 1. Due check (monthly semantics)
+
+The scan log lives on a single pinned issue titled `Regeluppdat scan log` labeled
+`loop:regeluppdat` (create it on the first ever run). Each run ends by commenting
+`` plus the run summary on it.
+If a marker for the **current calendar month** already exists, report "not due" and stop, unless
+the user invoked `/loop-regeluppdat` explicitly.
+
+## 2. Sweep the sources
+
+Cover every row; a source with nothing new is still reported as swept (silent truncation reads
+as "covered everything"). Window: since the previous run marker (default: the last 45 days),
+plus anything already announced with a future effective date.
+
+| Source | What to look for |
+|---|---|
+| skatteverket.se (nyheter + rättslig vägledning, ställningstaganden) | VAT rates/rules, arbetsgivaravgifter, traktamente/milersättning amounts, basbelopp, deadline changes, AGI changes |
+| bfn.se (beslut, nya/ändrade BFNAR, remisser) | K2/K3 changes, bokföringsregler, verifikationskrav |
+| bolagsverket.se (nyheter) | filing mandates (digital årsredovisning), fees, form changes |
+| regeringen.se + riksdagen.se (propositioner, SFS) | changes to BFL, ÅRL, ML (moms), SFL, ABL, IL that touch bookkeeping, invoicing, payroll, or retention |
+| bas.se (BAS-kontogruppen) | new BAS chart year, new/removed/renamed accounts, SRU mapping changes |
+| digg.se + EU ViDA track | e-invoicing/Peppol mandates and timelines |
+| Secondary sweep: srfkonsulterna.se, far.se news | catch anything the primary sources buried |
+
+Also sweep for date-triggered knowns: every January 1 and July 1 batch (rate years, basbelopp,
+skiktgränser, avgifter) and any transition our own docs promise (search the repo for hardcoded
+years/rates near their expiry).
+
+**Relevance filter.** Keep a finding only if it affects: bookkeeping/verifikation rules, VAT
+(rates, rutor, reverse charge, OSS), employer contributions or payroll (AGI, förmåner,
+traktamente, semester), tax rates or deadlines, chart of accounts/SRU, year-end or
+årsredovisning, SIE/INK2/NE formats, e-invoicing, document retention, or company law that our
+flows encode. General business news is out of scope.
+
+## 3. Verify against the codebase (the actual value of this loop)
+
+For each surviving finding, before filing anything:
+
+1. **Load the matching `swedish-*` skill** for the domain (vat, payroll, year-end-closing,
+ financial-reporting, e-invoicing, ...). Never assess Swedish domain questions from training
+ data.
+2. **Grep the anchors** to see whether the change is already handled:
+ - VAT: `lib/vat/`, `lib/bookkeeping/vat-entries.ts`, `lib/reports/` (moms), momsdeklaration rutor
+ - Payroll/rates: `lib/salary/calculation-engine.ts`, `lib/salary/lonevaxling.ts`, `lib/salary/semesterberedning.ts`
+ - Deadlines: `lib/tax/deadline-config.ts`, `lib/deadlines/`, `lib/tax/swedish-holidays.ts`
+ - Chart of accounts: `lib/bookkeeping/bas-data/` (currently BAS 2026), `bas-data/sru-mapping.ts`
+ - Year-end/bokslut: `lib/bokslut/`, `lib/core/bookkeeping/year-end-service.ts` (bolagsskatt)
+ - Formats: `lib/reports/` (SIE, INK2, NE-bilaga, SRU), `lib/skatteverket/`
+ - E-invoicing/Peppol: the `swedish-e-invoicing` skill documents our current stance
+3. Check `DECISIONS.md` and search issues **state:all** for the same change: it may already be
+ tracked, done, or explicitly declined.
+4. Classify: **handled** (code already correct, note it in the run summary), **gap** (file a
+ ticket), or **unclear** (file a ticket flagged for founder judgment; do not guess).
+
+The `.claude/skills/swedish-*` skills are themselves a compliance surface: if a law changed,
+the skill text is now stale. A confirmed change therefore usually yields a ticket with two
+checkboxes: update the code AND update the affected skill.
+
+## 4. File tickets (cap: 6 new issues per run)
+
+Fingerprint = the change's official identifier (SFS number, BFNAR number, Skatteverket dnr, or
+`/` when nothing better exists) plus the effective year, never a timestamp.
+Dedupe per `.claude/loops.md` (search state:all, comment instead of duplicate, reopen if
+recurring). Then file:
+
+- Title: `regeluppdat: (effective )`
+- Body: what changed (with source links), who it affects (EF/AB, which flows), what the code
+ does today (file:line evidence from step 3), suggested change, effective date, and
+ ``.
+- Labels: `loop:auto`, `loop:regeluppdat`, plus `bug` if we are already non-compliant today or
+ `enhancement` if the change is upcoming.
+- Anything already in force that we get wrong also gets `loop:needs-human` (compliance exposure
+ is a founder call, not a backlog item).
+
+If the sweep yields more than 6 gaps, file the 6 with the earliest effective dates and list the
+rest in the run summary so the next run (or a human) picks them up.
+
+## 5. Run marker + report
+
+Comment on the `Regeluppdat scan log` issue: ``, sources
+swept, findings kept/discarded by the relevance filter, handled vs gap vs unclear counts, issues
+filed (numbers), overflow list. That summary is the completion notification.
+
+## Anti-thrash
+
+Never re-file a fingerprint that a human closed without action: closed + `wontfix` (or a closing
+comment declining it) means the decision is made; note it in the run summary instead. If the
+same finding keeps resurfacing across sources, one ticket, many source links.
diff --git a/DECISIONS.md b/DECISIONS.md
index 333aa9d0..9d222b81 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -847,3 +847,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-08-08] extensions.schema.json enum also gained "stripe" while adding "shopify": the enum had drifted (stripe was enabled in extensions.config.json but missing from the schema, failing editor validation); fixed in the same touch since the file had to change anyway.
[2026-08-08] Login panel is method-stated (BankID hero default, remembered via accounted-login-method cookie) instead of a stacked method list: matches the Swedish bank/Fortnox convention, gives exactly one primary action per view; errors moved from boxed banner to a field-adjacent single line (NN/g 3/4/10), reset link surfaces from the second failed attempt.
[2026-08-09] Upsell FAB dismissal is session-scoped (sessionStorage), not persisted: closing the paywalled sheet or the pill's X hides all floating assistant UI for non-payers until the next browser session; a permanent dismissal would let one click silence the conversion surface forever, and payer/collapsed FAB behavior stays untouched.
+[2026-08-09] Regeluppdat + docs-freshness scans (#1417) built as local loop skills with due-date self-gating, not cloud crons: cloud routines were retired 2026-07-20, and session crons die at 7 days, so weekly/monthly cadence is achieved by loop-ignite running each loop when its run marker says it is due. loop-regeluppdat files tickets only (no auto-fix PRs): regulatory changes touch money math and compliance logic, which .claude/loops.md forbids loops from changing. Docs check diffs the live .md mirror routes against repo-built markdown (exact, canonicalised both sides) instead of diffing the gnubok-website checkout, so it also catches deployed-but-stale and route-404 states.
diff --git a/scripts/check-docs-freshness.mts b/scripts/check-docs-freshness.mts
new file mode 100644
index 00000000..3add955a
--- /dev/null
+++ b/scripts/check-docs-freshness.mts
@@ -0,0 +1,229 @@
+/**
+ * Docs-freshness check: verifies that the public API docs on
+ * https://docs.accounted.se match what this repo would generate today.
+ *
+ * The docs site is served by the separate gnubok-website repo from snapshot
+ * files (`*.generated.ts` exported via scripts/export-docs-to-website.mts,
+ * plus hand-copied duplicates for changelog/versioning/webhooks/cookbook).
+ * Nothing re-syncs them automatically, so they drift whenever an endpoint,
+ * error code, or content page changes here. Every docs page has a raw
+ * markdown mirror route (`/.md`), which makes the check exact: build
+ * each page from source, fetch the live mirror, diff.
+ *
+ * Run with `npx tsx scripts/check-docs-freshness.mts`.
+ * Exit codes: 0 = in sync, 1 = drift or missing pages, 2 = build/fetch failure.
+ * Used by the weekly `loop-docs-freshness` skill; keep the output format
+ * stable (the loop parses the FINGERPRINT lines for issue dedupe).
+ */
+import { createHash } from 'node:crypto'
+import { createRequire } from 'node:module'
+
+// `server-only` throws on import outside a Next.js server-component graph.
+// The reference builder pulls it in transitively (lib/api/v1/load-routes ->
+// every v1 route -> lib/init). Nothing here executes request-time code: it
+// only reads exported markdown builders, so a no-op stub is the honest
+// resolution. Same pattern as scripts/export-docs-to-website.mts.
+const require = createRequire(import.meta.url)
+const ModuleCtor = require('node:module') as {
+ _load: (request: string, ...rest: unknown[]) => unknown
+}
+const originalLoad = ModuleCtor._load
+ModuleCtor._load = function (request: string, ...rest: unknown[]) {
+ if (request === 'server-only') return {}
+ return originalLoad.call(this, request, ...rest)
+}
+
+// Explicit types: these are read inside buildExpectedPages(), and TS cannot
+// infer a closure-captured let assigned in a try block (implicit-any error
+// under next build's type check; the export script gets away with the bare
+// pattern only because it reads the variables at top level).
+let errors!: typeof import('@/lib/docs/content/errors')
+let reference!: typeof import('@/lib/docs/content/reference')
+let connectClaude!: typeof import('@/lib/docs/content/connect-claude')
+let changelog!: typeof import('@/lib/docs/content/changelog')
+let versioning!: typeof import('@/lib/docs/content/versioning')
+let webhooks!: typeof import('@/lib/docs/content/webhooks')
+let cookbook!: typeof import('@/lib/docs/content/cookbook')
+try {
+ errors = await import('@/lib/docs/content/errors')
+ reference = await import('@/lib/docs/content/reference')
+ connectClaude = await import('@/lib/docs/content/connect-claude')
+ changelog = await import('@/lib/docs/content/changelog')
+ versioning = await import('@/lib/docs/content/versioning')
+ webhooks = await import('@/lib/docs/content/webhooks')
+ cookbook = await import('@/lib/docs/content/cookbook')
+} finally {
+ ModuleCtor._load = originalLoad
+}
+
+const DOCS_BASE = process.env.DOCS_BASE_URL ?? 'https://docs.accounted.se'
+
+/**
+ * Same link-absolutising transform the export script applies: the website
+ * cannot serve root-relative /api/v1 links. Applied to BOTH sides before
+ * comparing so the check is insensitive to whether a hand-copied page was
+ * adapted or not.
+ */
+const APP_ORIGIN = 'https://app.gnubok.se'
+function canonicalise(md: string): string {
+ return md
+ .replaceAll('](/api/v1/', `](${APP_ORIGIN}/api/v1/`)
+ .replaceAll('](/.well-known/', `](${APP_ORIGIN}/.well-known/`)
+ .replaceAll('\r\n', '\n')
+ .split('\n')
+ .map((l) => l.trimEnd())
+ .join('\n')
+ .trim()
+}
+
+function sha12(s: string): string {
+ return createHash('sha256').update(s).digest('hex').slice(0, 12)
+}
+
+interface PageCheck {
+ /** Path of the raw-markdown mirror on the docs site, no leading slash. */
+ path: string
+ expected: string
+}
+
+function buildExpectedPages(): PageCheck[] {
+ const buildErrorReferenceMd = errors.buildErrorReferenceMd
+ const buildResourcePages = reference.buildResourcePages
+ const buildReferenceOverviewMd = reference.buildReferenceOverviewMd
+ const { COOKBOOK, buildPlaceholderMd } = cookbook
+ if (!buildErrorReferenceMd || !buildResourcePages || !buildReferenceOverviewMd || !COOKBOOK) {
+ console.error('Missing builder exports; the content modules changed shape.')
+ process.exit(2)
+ }
+
+ const pages: PageCheck[] = [
+ { path: 'reference.md', expected: buildReferenceOverviewMd() },
+ { path: 'errors.md', expected: buildErrorReferenceMd() },
+ { path: 'connect-claude.md', expected: connectClaude.CONNECT_CLAUDE_MD },
+ { path: 'changelog.md', expected: changelog.CHANGELOG_MD },
+ { path: 'versioning.md', expected: versioning.VERSIONING_MD },
+ { path: 'webhooks.md', expected: webhooks.WEBHOOKS_MD },
+ ]
+ for (const page of buildResourcePages()) {
+ pages.push({ path: `reference/${page.slug}.md`, expected: page.markdown })
+ }
+ for (const entry of COOKBOOK) {
+ pages.push({
+ path: `cookbook/${entry.slug}.md`,
+ expected: entry.markdown ?? buildPlaceholderMd(entry),
+ })
+ }
+ return pages
+}
+
+type Verdict =
+ | { path: string; status: 'ok' }
+ | { path: string; status: 'missing'; fingerprint: string }
+ | {
+ path: string
+ status: 'drift'
+ fingerprint: string
+ firstDiffLine: number
+ expectedLine: string
+ liveLine: string
+ changedLines: number
+ }
+ | { path: string; status: 'fetch-error'; detail: string }
+
+async function checkPage(page: PageCheck): Promise {
+ const url = `${DOCS_BASE}/${page.path}`
+ let res: Response
+ try {
+ res = await fetch(url, { redirect: 'follow' })
+ } catch (e) {
+ return { path: page.path, status: 'fetch-error', detail: String(e) }
+ }
+ if (res.status === 404) {
+ // A page this repo generates that the docs site does not serve at all:
+ // typically a new API resource whose generated snapshot + nav entry were
+ // never exported to the website repo.
+ return { path: page.path, status: 'missing', fingerprint: sha12(`missing:${page.path}`) }
+ }
+ if (!res.ok) {
+ return { path: page.path, status: 'fetch-error', detail: `HTTP ${res.status}` }
+ }
+ const live = canonicalise(await res.text())
+ const expected = canonicalise(page.expected)
+ if (live === expected) return { path: page.path, status: 'ok' }
+
+ const expectedLines = expected.split('\n')
+ const liveLines = live.split('\n')
+ let firstDiffLine = 0
+ while (
+ firstDiffLine < Math.min(expectedLines.length, liveLines.length) &&
+ expectedLines[firstDiffLine] === liveLines[firstDiffLine]
+ ) {
+ firstDiffLine++
+ }
+ const maxLen = Math.max(expectedLines.length, liveLines.length)
+ let changedLines = Math.abs(expectedLines.length - liveLines.length)
+ for (let i = 0; i < Math.min(expectedLines.length, liveLines.length); i++) {
+ if (expectedLines[i] !== liveLines[i]) changedLines++
+ }
+ return {
+ path: page.path,
+ status: 'drift',
+ // Stable while the same pair of contents drifts; changes when either
+ // side changes, so a NEW drift after a fix reads as a new finding.
+ fingerprint: sha12(`${page.path}:${sha12(expected)}:${sha12(live)}`),
+ firstDiffLine: firstDiffLine + 1,
+ expectedLine: expectedLines[firstDiffLine] ?? '',
+ liveLine: liveLines[firstDiffLine] ?? '',
+ changedLines: Math.min(changedLines, maxLen),
+ }
+}
+
+async function main() {
+ const pages = buildExpectedPages()
+ console.log(`Checking ${pages.length} docs pages against ${DOCS_BASE} ...\n`)
+
+ const verdicts: Verdict[] = []
+ const CONCURRENCY = 6
+ for (let i = 0; i < pages.length; i += CONCURRENCY) {
+ const batch = pages.slice(i, i + CONCURRENCY)
+ verdicts.push(...(await Promise.all(batch.map(checkPage))))
+ }
+
+ const ok = verdicts.filter((v) => v.status === 'ok')
+ const drift = verdicts.filter((v) => v.status === 'drift')
+ const missing = verdicts.filter((v) => v.status === 'missing')
+ const failed = verdicts.filter((v) => v.status === 'fetch-error')
+
+ for (const v of verdicts) {
+ if (v.status === 'ok') continue
+ if (v.status === 'missing') {
+ console.log(`MISSING ${v.path}`)
+ console.log(` page exists in erp-base but the docs site returns 404`)
+ console.log(` FINGERPRINT ${v.fingerprint}`)
+ } else if (v.status === 'drift') {
+ console.log(`DRIFT ${v.path}`)
+ console.log(` ~${v.changedLines} differing line(s), first at line ${v.firstDiffLine}:`)
+ console.log(` repo builds: ${v.expectedLine.slice(0, 160)}`)
+ console.log(` site serves: ${v.liveLine.slice(0, 160)}`)
+ console.log(` FINGERPRINT ${v.fingerprint}`)
+ } else {
+ console.log(`ERROR ${v.path}: ${v.detail}`)
+ }
+ console.log('')
+ }
+
+ console.log(
+ `Summary: ${ok.length} in sync, ${drift.length} drifted, ${missing.length} missing, ${failed.length} fetch errors.`,
+ )
+
+ if (failed.length > 0) process.exit(2)
+ if (drift.length > 0 || missing.length > 0) {
+ console.log('\nTo fix: run `npx tsx scripts/export-docs-to-website.mts`, then port any')
+ console.log('hand-copied pages (changelog/versioning/webhooks/cookbook) and nav entries')
+ console.log('in the gnubok-website repo, and deploy it. See loop-docs-freshness skill.')
+ process.exit(1)
+ }
+ console.log('Docs are up to date.')
+}
+
+await main()